Skip to content

Commit 39d76e0

Browse files
authored
Merge pull request #201 from zainfathoni/feat/staging-environment
feat: set up staging environment
2 parents 7bc06d9 + 0ef35f8 commit 39d76e0

9 files changed

Lines changed: 394 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
on:
22
push:
3-
branches: [main]
3+
branches: [main, staging]
44
pull_request:
55
types: [opened, reopened, synchronize]
66

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Kamal deployment workflow for Staging
2+
# Deploys to staging after CI workflow passes on staging branch
3+
4+
name: Deploy Staging
5+
6+
on:
7+
workflow_run:
8+
workflows: [CI]
9+
types: [completed]
10+
branches: [staging]
11+
workflow_dispatch: # Allow manual deployment
12+
13+
concurrency:
14+
group: deploy-staging
15+
cancel-in-progress: true
16+
17+
jobs:
18+
deploy:
19+
name: Deploy to staging
20+
runs-on: ubuntu-latest
21+
environment: Staging
22+
# Only run if CI passed (or manual trigger)
23+
if: >
24+
github.event_name == 'workflow_dispatch' ||
25+
(github.event.workflow_run.conclusion == 'success' &&
26+
github.event.workflow_run.head_branch == 'staging')
27+
28+
steps:
29+
- name: Checkout repository
30+
uses: actions/checkout@v4
31+
32+
- name: Set up Docker Buildx
33+
uses: docker/setup-buildx-action@v3
34+
35+
- name: Login to GitHub Container Registry
36+
uses: docker/login-action@v3
37+
with:
38+
registry: ghcr.io
39+
username: ${{ github.actor }}
40+
password: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
41+
42+
- name: Build and push Docker image
43+
uses: docker/build-push-action@v6
44+
with:
45+
context: .
46+
push: true
47+
tags: |
48+
ghcr.io/zainfathoni/kelas.rumahberbagi.com:staging-${{ github.sha }}
49+
ghcr.io/zainfathoni/kelas.rumahberbagi.com:staging-latest
50+
cache-from: type=gha
51+
cache-to: type=gha,mode=max
52+
platforms: linux/amd64
53+
54+
- name: Set up Ruby for Kamal
55+
uses: ruby/setup-ruby@v1
56+
with:
57+
ruby-version: '3.3'
58+
59+
- name: Install Kamal
60+
run: gem install kamal
61+
62+
- name: Set up SSH agent
63+
uses: webfactory/ssh-agent@v0.9.0
64+
with:
65+
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
66+
67+
- name: Add VPS to known hosts
68+
run: ssh-keyscan -t rsa,ecdsa,ed25519 103.235.75.227 >> ~/.ssh/known_hosts
69+
70+
- name: Create Kamal secrets file
71+
run: |
72+
mkdir -p .kamal
73+
cat > .kamal/secrets << EOF
74+
KAMAL_REGISTRY_PASSWORD=${{ secrets.KAMAL_REGISTRY_PASSWORD }}
75+
SESSION_SECRET=${{ secrets.SESSION_SECRET }}
76+
MAGIC_LINK_SECRET=${{ secrets.MAGIC_LINK_SECRET }}
77+
MAILGUN_SENDING_KEY=${{ secrets.MAILGUN_SENDING_KEY }}
78+
MAILGUN_DOMAIN=${{ secrets.MAILGUN_DOMAIN }}
79+
EOF
80+
81+
- name: Deploy with Kamal
82+
run: kamal deploy -c config/deploy.staging.yml --skip-push --version staging-${{ github.sha }}
83+
env:
84+
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}

app/root.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { LinksFunction } from '@remix-run/node'
1+
import type { LinksFunction, LoaderFunction } from '@remix-run/node'
2+
import { json } from '@remix-run/node'
23
import {
34
Form,
45
isRouteErrorResponse,
@@ -8,6 +9,7 @@ import {
89
Outlet,
910
Scripts,
1011
ScrollRestoration,
12+
useLoaderData,
1113
useMatches,
1214
useRouteError,
1315
} from '@remix-run/react'
@@ -18,6 +20,24 @@ import fonts from './fonts.css'
1820
import { Footer } from './components/footer'
1921
import { Header } from '~/components/header'
2022

23+
type LoaderData = {
24+
isStaging: boolean
25+
}
26+
27+
export const loader: LoaderFunction = () => {
28+
return json<LoaderData>({
29+
isStaging: process.env.STAGING_ENVIRONMENT === 'true',
30+
})
31+
}
32+
33+
function StagingBanner() {
34+
return (
35+
<div className="bg-yellow-400 text-yellow-900 text-center py-1 px-4 text-sm font-medium">
36+
Staging Environment - Data may be reset at any time
37+
</div>
38+
)
39+
}
40+
2141
// https://remix.run/api/app#links
2242
export const links: LinksFunction = () => {
2343
return [
@@ -30,8 +50,10 @@ export const links: LinksFunction = () => {
3050
// https://remix.run/api/conventions#default-export
3151
// https://remix.run/api/conventions#route-filenames
3252
export default function App() {
53+
const { isStaging } = useLoaderData<LoaderData>()
3354
return (
3455
<Document>
56+
{isStaging && <StagingBanner />}
3557
<Layout>
3658
<Outlet />
3759
</Layout>

config/deploy.staging.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Kamal 2.0 deployment configuration for Kelas Rumah Berbagi (Staging)
2+
# See: https://kamal-deploy.org/docs/configuration/
3+
4+
service: kelas-staging
5+
6+
# Docker image configuration
7+
image: zainfathoni/kelas.rumahberbagi.com
8+
9+
# Deployment servers
10+
servers:
11+
web:
12+
hosts:
13+
- 103.235.75.227
14+
options:
15+
volume:
16+
- /data/kelas-staging/db:/app/prisma
17+
18+
# Container registry (GitHub Container Registry)
19+
registry:
20+
server: ghcr.io
21+
username: zainfathoni
22+
password:
23+
- KAMAL_REGISTRY_PASSWORD
24+
25+
# Kamal Proxy configuration (replaces Traefik in Kamal 2.0)
26+
proxy:
27+
ssl: true
28+
host: staging.kelas.rumahberbagi.com
29+
app_port: 3000
30+
healthcheck:
31+
path: /
32+
interval: 5
33+
timeout: 5
34+
35+
# Environment variables
36+
env:
37+
# Clear (non-secret) environment variables
38+
clear:
39+
NODE_ENV: production
40+
PORT: "3000"
41+
DATABASE_URL: file:/app/prisma/staging.db
42+
STAGING_ENVIRONMENT: "true"
43+
# Secret environment variables (loaded from .kamal/secrets)
44+
secret:
45+
- SESSION_SECRET
46+
- MAGIC_LINK_SECRET
47+
- MAILGUN_SENDING_KEY
48+
- MAILGUN_DOMAIN
49+
50+
# Build configuration
51+
builder:
52+
arch: amd64
53+
args:
54+
NODE_ENV: production
55+
56+
# Asset bridge for zero-downtime deployments
57+
# Copies public assets from old container to new one during deploy
58+
asset_path: /app/public

docs/deployment.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ deployment.
55

66
## Overview
77

8-
- **Production URL**: <https://kelas.rumahberbagi.com>
8+
| Environment | URL | Branch | Config |
9+
| ----------- | ---------------------------------------- | --------- | --------------------------- |
10+
| Production | <https://kelas.rumahberbagi.com> | `main` | `config/deploy.yml` |
11+
| Staging | <https://staging.kelas.rumahberbagi.com> | `staging` | `config/deploy.staging.yml` |
12+
913
- **VPS**: 103.235.75.227 (Jetorbit)
1014
- **Container Registry**: GitHub Container Registry (ghcr.io)
1115
- **SSL**: Managed by kamal-proxy (Let's Encrypt)
@@ -101,3 +105,53 @@ kamal app logs
101105
## Database Backups
102106

103107
See [backup-setup.md](backup-setup.md) for database backup configuration.
108+
109+
## Staging Environment
110+
111+
### Staging Deployment
112+
113+
Staging deploys automatically when CI passes on the `staging` branch:
114+
115+
```bash
116+
# Create staging branch from main
117+
git checkout main
118+
git pull
119+
git checkout -b staging
120+
git push -u origin staging
121+
```
122+
123+
### Staging Kamal Commands
124+
125+
```bash
126+
# Deploy to staging
127+
kamal deploy -c config/deploy.staging.yml
128+
129+
# View staging logs
130+
kamal app logs -c config/deploy.staging.yml
131+
132+
# Rollback staging
133+
kamal rollback <version> -c config/deploy.staging.yml
134+
135+
# SSH to staging container
136+
kamal app exec -c config/deploy.staging.yml -i bash
137+
```
138+
139+
### Database Sync (Production → Staging)
140+
141+
To refresh staging with production data:
142+
143+
```bash
144+
ssh root@103.235.75.227 /usr/local/bin/sync-staging-db.sh
145+
```
146+
147+
This script:
148+
149+
1. Stops the staging container
150+
2. Backs up current staging database
151+
3. Copies production database to staging
152+
4. Restarts the staging container
153+
154+
### Staging Backups
155+
156+
Staging database is backed up daily at 4 AM (1 hour after production) with 7-day
157+
retention. Backups are stored in `/var/backups/kelas-staging-db/`.

docs/loops/ralph-loop.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Repeated Work
2+
3+
/ralph-loop "Work on ONE of {issue-id} child, pick the most important one. If
4+
one child is already in_progress, resume. After ensuring that particular test
5+
passed, 'land the plane'.
6+
7+
When all children of that issue is closed, output <promise>DONE</promise>"
8+
--max-iterations 10 --completion-promise "DONE"

scripts/backup-staging-db.sh

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/bin/bash
2+
# Database backup script for Kelas Rumah Berbagi Staging
3+
# Backs up SQLite database with 7-day retention (shorter than production)
4+
#
5+
# Usage: ./scripts/backup-staging-db.sh
6+
# Setup: Run on VPS via cron for automated daily backups
7+
8+
set -euo pipefail
9+
10+
# Configuration
11+
DB_PATH="/data/kelas-staging/db/staging.db"
12+
BACKUP_DIR="/var/backups/kelas-staging-db"
13+
RETENTION_DAYS=7
14+
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
15+
BACKUP_FILE="${BACKUP_DIR}/staging_${TIMESTAMP}.db"
16+
17+
# Ensure backup directory exists
18+
mkdir -p "${BACKUP_DIR}"
19+
20+
# Check if database exists
21+
if [ ! -f "${DB_PATH}" ]; then
22+
echo "ERROR: Database not found at ${DB_PATH}"
23+
exit 1
24+
fi
25+
26+
# Create backup using SQLite backup command (safe for running database)
27+
if command -v sqlite3 &> /dev/null; then
28+
echo "Creating backup with sqlite3 .backup command..."
29+
sqlite3 "${DB_PATH}" ".backup '${BACKUP_FILE}'"
30+
else
31+
echo "sqlite3 not found, using file copy..."
32+
cp "${DB_PATH}" "${BACKUP_FILE}"
33+
fi
34+
35+
# Verify backup was created
36+
if [ ! -f "${BACKUP_FILE}" ]; then
37+
echo "ERROR: Backup file was not created"
38+
exit 1
39+
fi
40+
41+
BACKUP_SIZE=$(du -h "${BACKUP_FILE}" | cut -f1)
42+
echo "Backup created: ${BACKUP_FILE} (${BACKUP_SIZE})"
43+
44+
# Remove backups older than retention period
45+
echo "Cleaning up backups older than ${RETENTION_DAYS} days..."
46+
find "${BACKUP_DIR}" -name "staging_*.db" -type f -mtime +${RETENTION_DAYS} -delete
47+
48+
# List remaining backups
49+
BACKUP_COUNT=$(find "${BACKUP_DIR}" -name "staging_*.db" -type f | wc -l)
50+
echo "Total backups: ${BACKUP_COUNT}"
51+
52+
echo "Backup completed successfully"

scripts/download-staging-backup.sh

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/bin/bash
2+
# Download staging database backups from VPS to local machine
3+
#
4+
# Usage: ./scripts/download-staging-backup.sh [latest|all|<filename>]
5+
# latest - Download most recent backup (default)
6+
# all - Download all backups
7+
# <file> - Download specific backup file
8+
9+
set -euo pipefail
10+
11+
SCRIPT_DIR="$(dirname "$0")"
12+
DEPLOY_CONFIG="${SCRIPT_DIR}/../config/deploy.staging.yml"
13+
14+
# Extract VPS IP from Kamal config
15+
VPS_IP=$(grep -A2 'hosts:' "${DEPLOY_CONFIG}" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+')
16+
VPS_HOST="root@${VPS_IP}"
17+
REMOTE_BACKUP_DIR="/var/backups/kelas-staging-db"
18+
LOCAL_BACKUP_DIR="$(dirname "$0")/../prisma/backups/staging"
19+
20+
# Ensure local backup directory exists
21+
mkdir -p "${LOCAL_BACKUP_DIR}"
22+
23+
MODE="${1:-latest}"
24+
25+
case "${MODE}" in
26+
latest)
27+
echo "Fetching latest staging backup..."
28+
LATEST=$(ssh "${VPS_HOST}" "ls -t ${REMOTE_BACKUP_DIR}/staging_*.db 2>/dev/null | head -1")
29+
if [ -z "${LATEST}" ]; then
30+
echo "ERROR: No backups found on server"
31+
exit 1
32+
fi
33+
FILENAME=$(basename "${LATEST}")
34+
echo "Downloading ${FILENAME}..."
35+
scp "${VPS_HOST}:${LATEST}" "${LOCAL_BACKUP_DIR}/"
36+
echo "Downloaded to ${LOCAL_BACKUP_DIR}/${FILENAME}"
37+
;;
38+
all)
39+
echo "Downloading all staging backups..."
40+
scp "${VPS_HOST}:${REMOTE_BACKUP_DIR}/staging_*.db" "${LOCAL_BACKUP_DIR}/"
41+
echo "Downloaded to ${LOCAL_BACKUP_DIR}/"
42+
ls -lh "${LOCAL_BACKUP_DIR}"
43+
;;
44+
*)
45+
echo "Downloading ${MODE}..."
46+
scp "${VPS_HOST}:${REMOTE_BACKUP_DIR}/${MODE}" "${LOCAL_BACKUP_DIR}/"
47+
echo "Downloaded to ${LOCAL_BACKUP_DIR}/${MODE}"
48+
;;
49+
esac
50+
51+
echo "Done"

0 commit comments

Comments
 (0)