Start Tomorrow - Everything Explained Simply
Table of Contents
- What We Are Building
- Key Words Dictionary
- Day 1: Install K3s
- Day 2: Install Basic Tools
- Day 3: Create Your First App
- Day 4: Setup Auto-Deploy
- Day 5: Connect Dashboard
- Week 2: Add All Features
- Common Problems & Solutions
- Complete Scripts Ready to Use
Part 1: What We Are Building
Simple Explanation
Current Situation (BAD):
- You manually create subdomain for each client (3 hours work)
- You manually update each client’s system
- If system crashes, you fix manually
- Managing 150+ clients is very hard
New System (GOOD):
- Computer creates subdomain automatically (5 minutes)
- GitHub automatically builds and deploys your code
- If system crashes, K3s fixes it automatically
- Managing 1000+ clients becomes easy
Picture of New System
Your Code (GitHub)
↓ (automatic)
Build System (GitHub Actions)
↓ (automatic)
K3s Server (Your Ubuntu Server)
↓ (automatic)
150+ Clients (Each in separate container)
↓ (automatic)
MongoDB Database (Your existing database)
What Each Part Does
| Part | What It Does | Why You Need It |
|---|---|---|
| K3s | Manages all containers | Like a smart PM2 that fixes itself |
| Docker | Packages your app | Makes your app portable |
| GitHub Actions | Builds code automatically | No manual building |
| Helm | Template for clients | Create 100 clients with same template |
| ArgoCD | Deploys automatically | Watches GitHub and deploys |
| Traefik | Routes web traffic | Like nginx but automatic |
Part 2: Key Words Dictionary
Simple Words for Complex Things
| Complex Word | Simple Meaning | Example |
|---|---|---|
| Container | Box with your app inside | Like a mini-computer for each client |
| Pod | Container running in K3s | Your app running |
| Node | Physical server | Your Ubuntu server |
| Cluster | Group of servers | Your 3 Ubuntu servers working together |
| Deployment | Instructions to run app | “Run 3 copies of my app” |
| Service | Internal web address | How pods talk to each other |
| Ingress | External web address | How internet reaches your app |
| Namespace | Isolated area | Each client gets own namespace |
| Helm Chart | Template | Like a form you fill for each client |
| Image | Packaged app | Your code turned into Docker image |
| Registry | Image storage | Where Docker images are saved |
| kubectl | K3s command tool | How you talk to K3s |
| YAML | Configuration file | Settings file (like JSON) |
Part 3: Day 1 - Install K3s
🎯 Goal for Day 1
Install K3s on your Ubuntu server and verify it works.
📝 What You Need Before Starting
✅ 1 Ubuntu server (minimum: 4 CPU, 8GB RAM, 100GB disk)
✅ Root or sudo access
✅ Internet connection
✅ Ports open: 6443, 80, 443
✅ Your server IP address
Step 1.1: Connect to Your Server
# From your computer
ssh root@YOUR_SERVER_IP
# Example:
ssh [email protected]
🔑 Key Points:
- Replace YOUR_SERVER_IP with your actual IP
- Use your username if not root
- You should see Ubuntu terminal after connecting
Step 1.2: Update Your Server
# Update package list
sudo apt update
# Upgrade all packages
sudo apt upgrade -y
# Install basic tools
sudo apt install -y curl wget git vim
🔑 Key Points:
apt update= Get list of new softwareapt upgrade= Install new versions-y= Say “yes” automatically- This takes 5-10 minutes
Step 1.3: Configure Firewall
# Allow K3s port
sudo ufw allow 6443/tcp
# Allow web traffic
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Allow Kubernetes port
sudo ufw allow 10250/tcp
# Enable firewall
sudo ufw enable
# Check status
sudo ufw status
🔑 Key Points:
- Firewall = Security that blocks unwanted connections
- Port 6443 = K3s API (how you control K3s)
- Port 80 = HTTP (web traffic)
- Port 443 = HTTPS (secure web traffic)
- Port 10250 = Kubelet (K3s internal communication)
Step 1.4: Install K3s
# Download and install K3s
curl -sfL https://get.k3s.io | sh -
# Wait 30 seconds for K3s to start
sleep 30
# Check if K3s is running
sudo systemctl status k3s
# Get list of nodes
sudo k3s kubectl get nodes
🔑 Key Points:
- This installs K3s in 1 minute
- K3s starts automatically
- You should see “active (running)” in green
- You should see your server name in nodes list
Step 1.5: Configure kubectl (Command Tool)
# Create config directory
mkdir -p ~/.kube
# Copy K3s config
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
# Set correct permissions
sudo chown $(whoami) ~/.kube/config
chmod 600 ~/.kube/config
# Install kubectl
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
# Test kubectl
kubectl get nodes
🔑 Key Points:
- kubectl = Tool to control K3s
- Config file = Tells kubectl how to connect to K3s
- You should see your server name when running
kubectl get nodes
Step 1.6: Verify Everything Works
# Check all K3s components
kubectl get all -A
# You should see:
# ✅ kube-system namespace with pods
# ✅ All pods showing "Running"
# ✅ Traefik pod (this is your ingress)
🔑 Key Points:
-A= Show all namespaces- All pods should be “Running” or “Completed”
- If any pod is “Error” or “CrashLoopBackOff”, there’s a problem
🎉 Day 1 Complete!
What you achieved:
- ✅ K3s installed and running
- ✅ kubectl configured
- ✅ Firewall configured
- ✅ Ready for Day 2
Part 4: Day 2 - Install Basic Tools
🎯 Goal for Day 2
Install Helm, Docker Registry, and ArgoCD for automatic deployments.
Step 2.1: Install Helm (Package Manager)
# Download Helm installer
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Verify Helm installed
helm version
# You should see version 3.x.x
🔑 Key Points:
- Helm = Package manager for K3s (like npm for Node.js)
- Helm helps install complex applications easily
- Helm uses “charts” (templates) to install apps
Step 2.2: Install Docker on Server
# Install Docker
curl -fsSL https://get.docker.com | sh
# Add your user to docker group
sudo usermod -aG docker $USER
# Restart shell or logout/login
exit
# Then SSH back in
# Test Docker
docker version
🔑 Key Points:
- Docker = Tool to build container images
- We need Docker to build your app images
- Adding user to docker group = Run docker without sudo
Step 2.3: Setup Local Docker Registry
# Create namespace for registry
kubectl create namespace docker-registry
# Create registry deployment
kubectl create deployment docker-registry \
--image=registry:2 \
--namespace=docker-registry
# Expose registry
kubectl expose deployment docker-registry \
--type=NodePort \
--port=5000 \
--namespace=docker-registry
# Get registry port
kubectl get svc -n docker-registry
# You'll see something like 5000:32000/TCP
# The 32000 is your registry port (yours will be different)
🔑 Key Points:
- Registry = Place to store Docker images
- Local registry = Free alternative to Docker Hub
- NodePort = Makes registry accessible from outside
- Note your port number (like 32000) - you’ll need it
Step 2.4: Test Docker Registry
# Tag and push test image
docker pull nginx:latest
docker tag nginx:latest localhost:32000/nginx:test
docker push localhost:32000/nginx:test
# If successful, you'll see "Pushed"
🔑 Key Points:
- Replace 32000 with your actual port
- This tests if registry works
- “Pushed” means registry is working
Step 2.5: Install ArgoCD (Auto-Deployment)
# Create ArgoCD namespace
kubectl create namespace argocd
# Install ArgoCD
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# Wait for ArgoCD to start (2-3 minutes)
kubectl wait --for=condition=Ready pods --all -n argocd --timeout=300s
# Get ArgoCD password
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
# Save this password! You need it to login
# Example password: 4Xy2PqR8nNmZwL3K
🔑 Key Points:
- ArgoCD = Watches GitHub and deploys automatically
- This takes 2-3 minutes to install
- Save the password - you need it!
- Username is always “admin”
Step 2.6: Access ArgoCD UI
# Option 1: Port forward (temporary)
kubectl port-forward svc/argocd-server -n argocd 8080:443 &
# Access at: https://localhost:8080
# Username: admin
# Password: (the one you got above)
# Install kubectl on macOS
brew install kubectl
# Copy your kubeconfig from the server, run on your (local)
scp user@YOUR_SERVER_IP:~/.kube/config ~/.kube/config
# Edit the kube config file from your local
nano ~/.kube/config
# from the line server: https://127.0.0.1:6443 replace with tour real IP address
# Verify connection to verify it connect to the server (local)
kubectl get pods -n argocd
# Start port-forwarding, this make temp forwarding to access the ArgoCD from your local machine (local)
kubectl port-forward svc/argocd-server -n argocd 8080:443
# Option 2: Expose via LoadBalancer (permanent)
# Note: this not recommended for production
kubectl patch svc argocd-server -n argocd -p '{"spec": {"type": "NodePort"}}'
# Get the port
kubectl get svc argocd-server -n argocd
# Access at: https://YOUR_SERVER_IP:PORT
🔑 Key Points:
- ArgoCD has web interface
- Use “admin” and password to login
- Accept the SSL warning (it’s safe)
- You’ll see ArgoCD dashboard
🎉 Day 2 Complete!
What you achieved:
- ✅ Helm installed (package manager)
- ✅ Docker installed (build images)
- ✅ Docker Registry running (store images)
- ✅ ArgoCD installed (auto-deployment)
Part 5: Day 3 - Create Your First App
🎯 Goal for Day 3
Deploy your first client application to K3s.
Step 3.1: Create Helm Chart Template
# Create charts directory
mkdir -p ~/charts
cd ~/charts
# Create chart for your SaaS app
helm create saas-app
# Go to chart directory
cd saas-app
🔑 Key Points:
- Chart = Template for your application
- This creates basic files you’ll edit
- All clients will use this template
Step 3.2: Edit Chart Values
# Edit values.yaml
nano values.yaml
# Delete everything and add this:
# Client configuration
client:
name: "" # Client name (e.g., pethouse)
subdomain: "" # Full domain (e.g., pethouse.yourdomain.com)
# Image configuration
image:
repository: localhost:32000/saas-app
tag: "latest"
pullPolicy: Always
# MongoDB configuration
mongodb:
uri: "" # MongoDB connection string
database: "" # Database name
# Resources
resources:
limits:
cpu: 500m # Maximum CPU (0.5 core)
memory: 512Mi # Maximum RAM
requests:
cpu: 100m # Minimum CPU (0.1 core)
memory: 256Mi # Minimum RAM
# Scaling
replicas: 1 # Number of pods
autoscaling:
enabled: true
minReplicas: 1
maxReplicas: 5
targetCPU: 70 # Scale at 70% CPU
🔑 Key Points:
- This file contains default settings
- Each client can override these values
- CPU: 1000m = 1 full CPU core
- Memory: 1024Mi = 1GB RAM
Step 3.3: Create Deployment Template
# Edit deployment template
vim templates/deployment.yaml
# Delete everything and add:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Values.client.name }}-app
labels:
app: {{ .Values.client.name }}
version: {{ .Values.image.tag }}
spec:
replicas: {{ .Values.replicas }}
selector:
matchLabels:
app: {{ .Values.client.name }}
template:
metadata:
labels:
app: {{ .Values.client.name }}
spec:
containers:
- name: app
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
ports:
- containerPort: 3000
env:
- name: CLIENT_NAME
value: {{ .Values.client.name }}
- name: MONGODB_URI
value: {{ .Values.mongodb.uri }}
- name: DATABASE_NAME
value: {{ .Values.mongodb.database }}
resources:
limits:
cpu: {{ .Values.resources.limits.cpu }}
memory: {{ .Values.resources.limits.memory }}
requests:
cpu: {{ .Values.resources.requests.cpu }}
memory: {{ .Values.resources.requests.memory }}
# Health checks
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
🔑 Key Points:
{{ .Values.xxx }}= Gets replaced with actual values- livenessProbe = Checks if app is alive
- readinessProbe = Checks if app is ready for traffic
- This creates pods for your app
Step 3.4: Create Service Template
# Edit service template
vim templates/service.yaml
# Delete everything and add:
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.client.name }}-service
spec:
type: ClusterIP
selector:
app: {{ .Values.client.name }}
ports:
- port: 80
targetPort: 3000
protocol: TCP
🔑 Key Points:
- Service = Internal address for your app
- ClusterIP = Only accessible inside K3s
- Port 80 = Service port
- Port 3000 = Your app port
Step 3.5: Create Ingress Template
# Edit ingress template
vim templates/ingress.yaml
# Delete everything and add:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Values.client.name }}-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
rules:
- host: {{ .Values.client.subdomain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Values.client.name }}-service
port:
number: 80
tls:
- hosts:
- {{ .Values.client.subdomain }}
secretName: {{ .Values.client.name }}-tls
🔑 Key Points:
- Ingress = External address (subdomain)
- Automatically gets SSL certificate
- Routes traffic from internet to your service
Step 3.6: Create Auto-Scaling Template
# Create HPA template
vim templates/hpa.yaml
# Add this content:
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ .Values.client.name }}-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ .Values.client.name }}-app
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPU }}
{{- end }}
🔑 Key Points:
- HPA = Horizontal Pod Autoscaler
- Automatically adds/removes pods based on CPU
- Scales between min and max replicas
- Only created if autoscaling.enabled = true
Step 3.7: Test Your First Deployment
# Create test namespace
kubectl create namespace test-client
# Create test values file
cat > test-values.yaml <<EOF
client:
name: testclient
subdomain: testclient.yourdomain.com
mongodb:
uri: "mongodb://user:pass@mongodb-server:27017/testclient_db"
database: "testclient_db"
image:
repository: nginx
tag: latest
EOF
# Deploy using Helm
helm install testclient ./saas-app \
--namespace test-client \
--values test-values.yaml
# Check deployment
kubectl get all -n test-client
# You should see:
# ✅ deployment.apps/testclient-app
# ✅ service/testclient-service
# ✅ pod/testclient-app-xxxxx (Running)
🔑 Key Points:
- This deploys nginx as test
- Replace with your actual app later
- All resources created automatically
- Pod should show “Running”
🎉 Day 3 Complete!
What you achieved:
- ✅ Created Helm chart template
- ✅ Configured deployment, service, ingress
- ✅ Added auto-scaling
- ✅ Deployed first test client
Part 6: Day 4 - Setup Auto-Deploy
🎯 Goal for Day 4
Setup GitHub Actions to build and deploy automatically.
Step 4.1: Create GitHub Repository
# On your local computer (not server)
# Create new repository for your app
mkdir my-saas-app
cd my-saas-app
git init
# Create basic Node.js app
cat > package.json <<EOF
{
"name": "saas-app",
"version": "1.0.0",
"scripts": {
"start": "node server.js",
"test": "echo 'Tests passed'"
}
}
EOF
# Create simple server
cat > server.js <<EOF
const http = require('http');
const mongoUri = process.env.MONGODB_URI;
const clientName = process.env.CLIENT_NAME;
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200);
res.end('OK');
} else if (req.url === '/ready') {
res.writeHead(200);
res.end('Ready');
} else {
res.writeHead(200);
res.end(\`Hello from \${clientName}\`);
}
});
server.listen(3000, () => {
console.log(\`Server running for \${clientName}\`);
});
EOF
# Create Dockerfile
cat > Dockerfile <<EOF
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
EOF
🔑 Key Points:
- This creates simple Node.js app
- Has /health and /ready endpoints for K3s
- Reads CLIENT_NAME from environment
- You’ll replace with your actual app
Step 4.2: Create GitHub Actions Workflow
# Create workflows directory
mkdir -p .github/workflows
# Create workflow file
cat > .github/workflows/build-deploy.yml <<EOF
name: Build and Deploy
# When to run
on:
push:
branches: [main, beta, develop]
pull_request:
branches: [main]
# Environment variables
env:
REGISTRY: localhost:32000 # Your registry (change port!)
IMAGE_NAME: saas-app
jobs:
# Job 1: Run tests
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install dependencies
run: npm install
- name: Run tests
run: npm test
# Job 2: Build and push image
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set version
id: version
run: |
# Create version from commit
SHORT_SHA=\$(echo \${{ github.sha }} | cut -c1-7)
BRANCH=\${GITHUB_REF#refs/heads/}
# Set version based on branch
if [[ "\$BRANCH" == "main" ]]; then
VERSION="lts-\$SHORT_SHA"
echo "version=\$VERSION" >> \$GITHUB_OUTPUT
echo "environment=lts" >> \$GITHUB_OUTPUT
elif [[ "\$BRANCH" == "beta" ]]; then
VERSION="beta-\$SHORT_SHA"
echo "version=\$VERSION" >> \$GITHUB_OUTPUT
echo "environment=beta" >> \$GITHUB_OUTPUT
else
VERSION="dev-\$SHORT_SHA"
echo "version=\$VERSION" >> \$GITHUB_OUTPUT
echo "environment=dev" >> \$GITHUB_OUTPUT
fi
- name: Build Docker image
run: |
docker build -t \${{ env.REGISTRY }}/\${{ env.IMAGE_NAME }}:\${{ steps.version.outputs.version }} .
docker tag \${{ env.REGISTRY }}/\${{ env.IMAGE_NAME }}:\${{ steps.version.outputs.version }} \${{ env.REGISTRY }}/\${{ env.IMAGE_NAME }}:latest
- name: Push to registry
run: |
# Note: In real setup, you'd push to your server's registry
# For now, this is example
echo "Would push: \${{ env.REGISTRY }}/\${{ env.IMAGE_NAME }}:\${{ steps.version.outputs.version }}"
- name: Update deployment file
run: |
# Create deployment record
cat > deployment.json <<END
{
"version": "\${{ steps.version.outputs.version }}",
"commit": "\${{ github.sha }}",
"branch": "\${GITHUB_REF#refs/heads/}",
"author": "\${{ github.actor }}",
"message": "\${{ github.event.head_commit.message }}",
"timestamp": "\$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"environment": "\${{ steps.version.outputs.environment }}"
}
END
# In real setup, send to your API
# curl -X POST https://your-api/deployments -d @deployment.json
EOF
🔑 Key Points:
- Runs on every push to main/beta/develop
- Creates version like “lts-abc123”
- Builds Docker image
- Tags based on branch (lts/beta/dev)
- You need to update REGISTRY port
Step 4.3: Push to GitHub
# Add all files
git add .
# Commit
git commit -m "Initial commit"
# Create repository on GitHub (via website)
# Then add remote
git remote add origin https://github.com/yourusername/my-saas-app.git
# Push
git push -u origin main
🔑 Key Points:
- Create repository on GitHub.com first
- Replace “yourusername” with your GitHub username
- GitHub Actions will run automatically
Step 4.4: Create Deployment Script
# Back on your server
cd ~
# Create scripts directory
mkdir -p k3s-scripts
cd k3s-scripts
# Create deployment script
cat > deploy-client.sh <<'EOF'
#!/bin/bash
# Script to deploy new client
# Usage: ./deploy-client.sh CLIENT_NAME SUBDOMAIN
CLIENT_NAME=$1
SUBDOMAIN=$2
if [ -z "$CLIENT_NAME" ] || [ -z "$SUBDOMAIN" ]; then
echo "Usage: ./deploy-client.sh CLIENT_NAME SUBDOMAIN"
echo "Example: ./deploy-client.sh pethouse pethouse.mydomain.com"
exit 1
fi
echo "======================================"
echo "Deploying new client: $CLIENT_NAME"
echo "Subdomain: $SUBDOMAIN"
echo "======================================"
# Step 1: Create namespace
echo "Creating namespace..."
kubectl create namespace client-$CLIENT_NAME
# Step 2: Create MongoDB URI (adjust for your MongoDB)
MONGO_URI="mongodb://username:password@mongodb-server:27017/${CLIENT_NAME}_db"
MONGO_DB="${CLIENT_NAME}_db"
# Step 3: Create values file
echo "Creating values file..."
cat > /tmp/${CLIENT_NAME}-values.yaml <<END
client:
name: $CLIENT_NAME
subdomain: $SUBDOMAIN
mongodb:
uri: "$MONGO_URI"
database: "$MONGO_DB"
image:
repository: localhost:32000/saas-app
tag: "latest"
autoscaling:
enabled: true
minReplicas: 1
maxReplicas: 5
targetCPU: 70
END
# Step 4: Deploy using Helm
echo "Deploying with Helm..."
helm install $CLIENT_NAME ~/charts/saas-app \
--namespace client-$CLIENT_NAME \
--values /tmp/${CLIENT_NAME}-values.yaml
# Step 5: Wait for deployment
echo "Waiting for pods to be ready..."
kubectl wait --for=condition=ready pod \
-l app=$CLIENT_NAME \
-n client-$CLIENT_NAME \
--timeout=120s
# Step 6: Check status
echo "======================================"
echo "Deployment complete!"
echo "======================================"
kubectl get all -n client-$CLIENT_NAME
echo ""
echo "Access at: https://$SUBDOMAIN"
echo "Note: DNS may take a few minutes to propagate"
EOF
# Make executable
chmod +x deploy-client.sh
🔑 Key Points:
- This script deploys new client
- Creates namespace automatically
- Uses Helm to deploy
- Waits for pods to be ready
- Shows final status
Step 4.5: Create Update Script
# Create update script
cat > update-client.sh <<'EOF'
#!/bin/bash
# Script to update client version
# Usage: ./update-client.sh CLIENT_NAME VERSION
CLIENT_NAME=$1
VERSION=$2
if [ -z "$CLIENT_NAME" ] || [ -z "$VERSION" ]; then
echo "Usage: ./update-client.sh CLIENT_NAME VERSION"
echo "Example: ./update-client.sh pethouse beta-abc123"
echo "Version options: latest, lts-xxx, beta-xxx, or commit hash"
exit 1
fi
echo "======================================"
echo "Updating client: $CLIENT_NAME"
echo "New version: $VERSION"
echo "======================================"
# Update using Helm
helm upgrade $CLIENT_NAME ~/charts/saas-app \
--namespace client-$CLIENT_NAME \
--reuse-values \
--set image.tag=$VERSION
# Watch rollout
kubectl rollout status deployment/${CLIENT_NAME}-app \
-n client-$CLIENT_NAME
echo "======================================"
echo "Update complete!"
echo "======================================"
kubectl get pods -n client-$CLIENT_NAME
EOF
# Make executable
chmod +x update-client.sh
🔑 Key Points:
- Updates existing client to new version
- Keeps all settings except image version
- Shows rollout progress
- Zero-downtime update
🎉 Day 4 Complete!
What you achieved:
- ✅ Created GitHub repository
- ✅ Setup GitHub Actions workflow
- ✅ Created deployment scripts
- ✅ Ready for automatic deployments
Part 7: Day 5 - Connect Dashboard
🎯 Goal for Day 5
Create API to control everything from your SaaS dashboard.
Step 5.1: Install Python and Flask
# Install Python and pip
sudo apt update
sudo apt install -y python3 python3-pip
# Install Flask
pip3 install flask pyyaml kubernetes
# Verify installation
python3 -c "import flask; print('Flask installed')"
🔑 Key Points:
- Flask = Simple web framework for API
- We’ll create REST API
- Your dashboard will call this API
Step 5.2: Create API Server
# Create API directory
mkdir -p ~/saas-api
cd ~/saas-api
# Create API file
cat > api.py <<'EOF'
from flask import Flask, request, jsonify
import subprocess
import json
import yaml
import os
from datetime import datetime
app = Flask(__name__)
# Store client information
clients = {}
# API Routes
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({'status': 'healthy'})
@app.route('/api/clients', methods=['GET'])
def list_clients():
"""List all clients"""
try:
# Get all namespaces starting with client-
result = subprocess.run(
['kubectl', 'get', 'namespaces', '-o', 'json'],
capture_output=True,
text=True
)
namespaces = json.loads(result.stdout)
client_list = []
for ns in namespaces['items']:
name = ns['metadata']['name']
if name.startswith('client-'):
client_name = name.replace('client-', '')
client_list.append({
'name': client_name,
'namespace': name,
'created': ns['metadata']['creationTimestamp']
})
return jsonify({
'success': True,
'clients': client_list,
'count': len(client_list)
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/clients/<name>', methods=['GET'])
def get_client(name):
"""Get specific client information"""
try:
# Get pods for client
result = subprocess.run(
['kubectl', 'get', 'pods', '-n', f'client-{name}', '-o', 'json'],
capture_output=True,
text=True
)
if result.returncode != 0:
return jsonify({'success': False, 'error': 'Client not found'}), 404
pods = json.loads(result.stdout)
pod_list = []
for pod in pods['items']:
pod_list.append({
'name': pod['metadata']['name'],
'status': pod['status']['phase'],
'ready': all(c['ready'] for c in pod['status'].get('containerStatuses', []))
})
return jsonify({
'success': True,
'client': name,
'pods': pod_list
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/clients', methods=['POST'])
def create_client():
"""Create new client"""
try:
data = request.json
name = data['name']
subdomain = data['subdomain']
# Run deployment script
result = subprocess.run(
['/home/ubuntu/k3s-scripts/deploy-client.sh', name, subdomain],
capture_output=True,
text=True
)
if result.returncode == 0:
return jsonify({
'success': True,
'message': f'Client {name} created successfully',
'output': result.stdout
})
else:
return jsonify({
'success': False,
'error': result.stderr
}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/clients/<name>', methods=['DELETE'])
def delete_client(name):
"""Delete client"""
try:
# Delete namespace (this deletes everything)
result = subprocess.run(
['kubectl', 'delete', 'namespace', f'client-{name}'],
capture_output=True,
text=True
)
if result.returncode == 0:
return jsonify({
'success': True,
'message': f'Client {name} deleted'
})
else:
return jsonify({
'success': False,
'error': result.stderr
}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/clients/<name>/version', methods=['PUT'])
def update_client_version(name):
"""Update client to specific version"""
try:
data = request.json
version = data['version']
# Run update script
result = subprocess.run(
['/home/ubuntu/k3s-scripts/update-client.sh', name, version],
capture_output=True,
text=True
)
if result.returncode == 0:
return jsonify({
'success': True,
'message': f'Client {name} updated to {version}',
'output': result.stdout
})
else:
return jsonify({
'success': False,
'error': result.stderr
}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/clients/<name>/scale', methods=['PUT'])
def scale_client(name):
"""Scale client pods"""
try:
data = request.json
replicas = data['replicas']
# Scale deployment
result = subprocess.run(
['kubectl', 'scale', 'deployment', f'{name}-app',
'-n', f'client-{name}', '--replicas', str(replicas)],
capture_output=True,
text=True
)
if result.returncode == 0:
return jsonify({
'success': True,
'message': f'Scaled {name} to {replicas} replicas'
})
else:
return jsonify({
'success': False,
'error': result.stderr
}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/clients/<name>/restart', methods=['POST'])
def restart_client(name):
"""Restart client pods"""
try:
# Restart deployment
result = subprocess.run(
['kubectl', 'rollout', 'restart', 'deployment',
f'{name}-app', '-n', f'client-{name}'],
capture_output=True,
text=True
)
if result.returncode == 0:
return jsonify({
'success': True,
'message': f'Restarted {name} pods'
})
else:
return jsonify({
'success': False,
'error': result.stderr
}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/versions', methods=['GET'])
def list_versions():
"""List available versions"""
# In real setup, this would check your registry
return jsonify({
'success': True,
'versions': [
{'tag': 'latest', 'type': 'latest'},
{'tag': 'lts-abc123', 'type': 'lts'},
{'tag': 'beta-def456', 'type': 'beta'},
{'tag': 'dev-ghi789', 'type': 'dev'}
]
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
EOF
🔑 Key Points:
- This API controls K3s
- Each endpoint does specific task
- Returns JSON for easy use
- Calls your scripts internally
Step 5.3: Create Systemd Service
# Create service file
sudo cat > /etc/systemd/system/saas-api.service <<EOF
[Unit]
Description=SaaS API Server
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/saas-api
ExecStart=/usr/bin/python3 /home/ubuntu/saas-api/api.py
Restart=always
[Install]
WantedBy=multi-user.target
EOF
# Start service
sudo systemctl daemon-reload
sudo systemctl start saas-api
sudo systemctl enable saas-api
# Check status
sudo systemctl status saas-api
🔑 Key Points:
- Service starts automatically
- Restarts if crashes
- Runs on port 5000
- Access at http://YOUR_SERVER_IP:5000
Step 5.4: Test API
# Test health endpoint
curl http://localhost:5000/health
# List clients
curl http://localhost:5000/api/clients
# Create test client
curl -X POST http://localhost:5000/api/clients \
-H "Content-Type: application/json" \
-d '{"name":"testapi","subdomain":"testapi.domain.com"}'
# Check specific client
curl http://localhost:5000/api/clients/testapi
# Update version
curl -X PUT http://localhost:5000/api/clients/testapi/version \
-H "Content-Type: application/json" \
-d '{"version":"latest"}'
🔑 Key Points:
- Test each endpoint
- Should return JSON responses
- Check for “success”: true
- Errors show helpful messages
Step 5.5: Create Dashboard HTML
# Create dashboard file
cat > ~/saas-api/dashboard.html <<'EOF'
<!DOCTYPE html>
<html>
<head>
<title>K3s SaaS Dashboard</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 10px;
}
h1 {
color: #333;
border-bottom: 2px solid #4CAF50;
padding-bottom: 10px;
}
.client-card {
border: 1px solid #ddd;
padding: 15px;
margin: 10px 0;
border-radius: 5px;
background: #fafafa;
}
.status-running {
color: green;
font-weight: bold;
}
.status-pending {
color: orange;
font-weight: bold;
}
.status-failed {
color: red;
font-weight: bold;
}
button {
background: #4CAF50;
color: white;
padding: 8px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
margin: 2px;
}
button:hover {
background: #45a049;
}
button.danger {
background: #f44336;
}
button.danger:hover {
background: #da190b;
}
input, select {
padding: 8px;
margin: 5px;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-section {
background: #f0f0f0;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #4CAF50;
color: white;
}
.message {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
</style>
</head>
<body>
<div class="container">
<h1>🚀 K3s SaaS Management Dashboard</h1>
<div id="message"></div>
<!-- Create New Client Section -->
<div class="form-section">
<h2>➕ Create New Client</h2>
<input type="text" id="newClientName" placeholder="Client name (e.g., pethouse)">
<input type="text" id="newClientDomain" placeholder="Subdomain (e.g., pethouse.domain.com)">
<button onclick="createClient()">Create Client</button>
</div>
<!-- Client List -->
<h2>📋 Current Clients</h2>
<button onclick="loadClients()">🔄 Refresh</button>
<div id="clientsList"></div>
<!-- Version Management -->
<div class="form-section">
<h2>📦 Version Management</h2>
<select id="versionSelect">
<option value="latest">Latest</option>
<option value="lts-latest">LTS Latest</option>
<option value="beta-latest">Beta Latest</option>
</select>
<button onclick="loadVersions()">Load Versions</button>
<div id="versionsList"></div>
</div>
</div>
<script>
const API_URL = 'http://' + window.location.hostname + ':5000';
// Show message
function showMessage(text, type = 'success') {
const messageDiv = document.getElementById('message');
messageDiv.className = 'message ' + type;
messageDiv.textContent = text;
setTimeout(() => {
messageDiv.textContent = '';
messageDiv.className = '';
}, 5000);
}
// Load clients list
async function loadClients() {
try {
const response = await fetch(API_URL + '/api/clients');
const data = await response.json();
const listDiv = document.getElementById('clientsList');
if (data.success && data.clients.length > 0) {
let html = '<table><tr><th>Client Name</th><th>Namespace</th><th>Created</th><th>Actions</th></tr>';
for (const client of data.clients) {
html += '<tr>';
html += '<td>' + client.name + '</td>';
html += '<td>' + client.namespace + '</td>';
html += '<td>' + new Date(client.created).toLocaleDateString() + '</td>';
html += '<td>';
html += '<button onclick="getClientDetails(\'' + client.name + '\')">📊 Details</button>';
html += '<button onclick="updateClient(\'' + client.name + '\')">🔄 Update</button>';
html += '<button onclick="restartClient(\'' + client.name + '\')">🔃 Restart</button>';
html += '<button class="danger" onclick="deleteClient(\'' + client.name + '\')">🗑️ Delete</button>';
html += '</td>';
html += '</tr>';
}
html += '</table>';
listDiv.innerHTML = html;
} else {
listDiv.innerHTML = '<p>No clients found. Create your first client above!</p>';
}
} catch (error) {
showMessage('Error loading clients: ' + error, 'error');
}
}
// Create new client
async function createClient() {
const name = document.getElementById('newClientName').value;
const subdomain = document.getElementById('newClientDomain').value;
if (!name || !subdomain) {
showMessage('Please enter client name and subdomain', 'error');
return;
}
try {
const response = await fetch(API_URL + '/api/clients', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, subdomain: subdomain})
});
const data = await response.json();
if (data.success) {
showMessage('Client ' + name + ' created successfully!', 'success');
document.getElementById('newClientName').value = '';
document.getElementById('newClientDomain').value = '';
loadClients();
} else {
showMessage('Error: ' + data.error, 'error');
}
} catch (error) {
showMessage('Error creating client: ' + error, 'error');
}
}
// Get client details
async function getClientDetails(name) {
try {
const response = await fetch(API_URL + '/api/clients/' + name);
const data = await response.json();
if (data.success) {
let status = 'Pods for ' + name + ':\n';
data.pods.forEach(pod => {
status += '- ' + pod.name + ' (' + pod.status + ')\n';
});
alert(status);
} else {
showMessage('Error: ' + data.error, 'error');
}
} catch (error) {
showMessage('Error getting details: ' + error, 'error');
}
}
// Update client version
async function updateClient(name) {
const version = prompt('Enter version (latest, lts-xxx, beta-xxx):');
if (!version) return;
try {
const response = await fetch(API_URL + '/api/clients/' + name + '/version', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({version: version})
});
const data = await response.json();
if (data.success) {
showMessage('Client ' + name + ' updated to ' + version, 'success');
} else {
showMessage('Error: ' + data.error, 'error');
}
} catch (error) {
showMessage('Error updating client: ' + error, 'error');
}
}
// Restart client
async function restartClient(name) {
if (!confirm('Restart client ' + name + '?')) return;
try {
const response = await fetch(API_URL + '/api/clients/' + name + '/restart', {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showMessage('Client ' + name + ' restarted', 'success');
} else {
showMessage('Error: ' + data.error, 'error');
}
} catch (error) {
showMessage('Error restarting client: ' + error, 'error');
}
}
// Delete client
async function deleteClient(name) {
if (!confirm('Delete client ' + name + '? This cannot be undone!')) return;
try {
const response = await fetch(API_URL + '/api/clients/' + name, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showMessage('Client ' + name + ' deleted', 'success');
loadClients();
} else {
showMessage('Error: ' + data.error, 'error');
}
} catch (error) {
showMessage('Error deleting client: ' + error, 'error');
}
}
// Load available versions
async function loadVersions() {
try {
const response = await fetch(API_URL + '/api/versions');
const data = await response.json();
if (data.success) {
const listDiv = document.getElementById('versionsList');
let html = '<h3>Available Versions:</h3><ul>';
data.versions.forEach(v => {
html += '<li>' + v.tag + ' (' + v.type + ')</li>';
});
html += '</ul>';
listDiv.innerHTML = html;
}
} catch (error) {
showMessage('Error loading versions: ' + error, 'error');
}
}
// Load clients on page load
window.onload = function() {
loadClients();
loadVersions();
};
</script>
</body>
</html>
EOF
# Serve dashboard
cd ~/saas-api
python3 -m http.server 8080 &
🔑 Key Points:
- Simple HTML dashboard
- Uses JavaScript to call API
- Shows all clients
- Create, update, delete clients
- Access at http://YOUR_SERVER_IP:8080/dashboard.html
🎉 Day 5 Complete!
What you achieved:
- ✅ Created REST API
- ✅ API controls all K3s operations
- ✅ Created web dashboard
- ✅ Can manage clients from browser
Part 8: Week 2 - Add All Features
Quick Setup for Remaining Features
Add SSL Certificates (30 minutes)
# Install cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
# Wait for cert-manager
kubectl wait --for=condition=Ready pods --all -n cert-manager --timeout=300s
# Create Let's Encrypt issuer
cat > letsencrypt-issuer.yaml <<EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: [email protected] # Change this!
privateKeySecretRef:
name: letsencrypt-key
solvers:
- http01:
ingress:
class: traefik
EOF
kubectl apply -f letsencrypt-issuer.yaml
🔑 Key Points:
- Free SSL certificates
- Automatic renewal
- Works with your ingress
Add Monitoring (1 hour)
# Install Prometheus and Grafana
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install monitoring prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--create-namespace \
--set grafana.adminPassword=admin123 # Change password!
# Access Grafana
kubectl port-forward -n monitoring svc/monitoring-grafana 3000:80
# Login: admin / admin123
# URL: http://localhost:3000
🔑 Key Points:
- Prometheus collects metrics
- Grafana shows dashboards
- Pre-built K8s dashboards
Add Backup System (30 minutes)
# Create backup script
cat > backup-clients.sh <<'EOF'
#!/bin/bash
# Backup all client configs
BACKUP_DIR="/backups/k3s/$(date +%Y%m%d)"
mkdir -p $BACKUP_DIR
# Backup each client namespace
for ns in $(kubectl get ns -o name | grep client- | cut -d/ -f2); do
echo "Backing up $ns..."
kubectl get all -n $ns -o yaml > $BACKUP_DIR/$ns.yaml
done
echo "Backup complete: $BACKUP_DIR"
EOF
chmod +x backup-clients.sh
# Add to crontab (daily at 2 AM)
(crontab -l 2>/dev/null; echo "0 2 * * * /home/ubuntu/backup-clients.sh") | crontab -
🔑 Key Points:
- Daily automatic backups
- Saves all client configurations
- Easy restore if needed
Part 9: Common Problems & Solutions
Problem Reference Guide
| Problem | Solution |
|---|---|
| Pod stuck in “Pending” | Check resources: kubectl describe pod POD_NAME -n NAMESPACE |
| Pod keeps restarting | Check logs: kubectl logs POD_NAME -n NAMESPACE |
| Can’t access website | Check ingress: kubectl get ingress -A |
| Out of disk space | Clean images: docker system prune -a |
| MongoDB connection fails | Check secret: kubectl get secret -n NAMESPACE |
| SSL not working | Check cert: kubectl describe certificate -n NAMESPACE |
| High CPU usage | Check metrics: kubectl top nodes and kubectl top pods -A |
| Deployment failed | Rollback: helm rollback CLIENT_NAME -n NAMESPACE |
Debug Commands
# Check pod issues
kubectl describe pod POD_NAME -n NAMESPACE
# View pod logs
kubectl logs POD_NAME -n NAMESPACE
# Get into pod shell
kubectl exec -it POD_NAME -n NAMESPACE -- /bin/sh
# Check events
kubectl get events -n NAMESPACE --sort-by='.lastTimestamp'
# Check resource usage
kubectl top nodes
kubectl top pods -A
# Force delete stuck pod
kubectl delete pod POD_NAME -n NAMESPACE --force --grace-period=0
Part 10: Complete Scripts Ready to Use
Master Installation Script
#!/bin/bash
# Save as: install-everything.sh
echo "================================"
echo "K3s Complete Installation Script"
echo "================================"
# Step 1: Install K3s
echo "Installing K3s..."
curl -sfL https://get.k3s.io | sh -
sleep 30
# Step 2: Configure kubectl
echo "Configuring kubectl..."
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(whoami) ~/.kube/config
# Step 3: Install Helm
echo "Installing Helm..."
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Step 4: Install Docker
echo "Installing Docker..."
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Step 5: Create registry
echo "Creating Docker registry..."
kubectl create namespace docker-registry
kubectl create deployment docker-registry --image=registry:2 --namespace=docker-registry
kubectl expose deployment docker-registry --type=NodePort --port=5000 --namespace=docker-registry
# Step 6: Install ArgoCD
echo "Installing ArgoCD..."
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# Step 7: Install cert-manager
echo "Installing cert-manager..."
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
# Step 8: Install monitoring
echo "Installing Prometheus and Grafana..."
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install monitoring prometheus-community/kube-prometheus-stack --namespace monitoring --create-namespace
echo "================================"
echo "Installation Complete!"
echo "================================"
echo "Next steps:"
echo "1. Logout and login again (for Docker)"
echo "2. Get ArgoCD password: kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath=\"{.data.password}\" | base64 -d"
echo "3. Access Grafana: kubectl port-forward -n monitoring svc/monitoring-grafana 3000:80"
Client Management Script
#!/bin/bash
# Save as: client-manager.sh
case "$1" in
create)
CLIENT=$2
DOMAIN=$3
kubectl create namespace client-$CLIENT
helm install $CLIENT ./saas-app --namespace client-$CLIENT \
--set client.name=$CLIENT \
--set client.subdomain=$DOMAIN
echo "Client $CLIENT created"
;;
delete)
CLIENT=$2
kubectl delete namespace client-$CLIENT
echo "Client $CLIENT deleted"
;;
list)
echo "Current clients:"
kubectl get namespaces | grep client-
;;
update)
CLIENT=$2
VERSION=$3
helm upgrade $CLIENT ./saas-app --namespace client-$CLIENT \
--set image.tag=$VERSION
echo "Client $CLIENT updated to $VERSION"
;;
status)
CLIENT=$2
kubectl get all -n client-$CLIENT
;;
*)
echo "Usage: $0 {create|delete|list|update|status} [client] [options]"
;;
esac
🚀 Start Tomorrow Checklist
Day 1 (4 hours)
- [ ] Morning: Prepare Ubuntu server
- [ ] Install K3s (30 minutes)
- [ ] Install kubectl (15 minutes)
- [ ] Test everything works (15 minutes)
- [ ] Break
- [ ] Install Helm (15 minutes)
- [ ] Install Docker (30 minutes)
- [ ] Setup registry (30 minutes)
- [ ] Install ArgoCD (45 minutes)
Day 2 (4 hours)
- [ ] Create Helm chart (1 hour)
- [ ] Deploy test client (30 minutes)
- [ ] Create scripts (1 hour)
- [ ] Test scripts (30 minutes)
- [ ] Setup GitHub repo (1 hour)
Day 3 (4 hours)
- [ ] Create API (2 hours)
- [ ] Create dashboard (1 hour)
- [ ] Test everything (1 hour)
Day 4-5 (8 hours)
- [ ] Migrate first 3 clients
- [ ] Fix any issues
- [ ] Document problems
- [ ] Train team
Week 2
- [ ] Add monitoring
- [ ] Add backups
- [ ] Migrate 10 more clients
- [ ] Optimize performance
📞 Need Help?
Resources
- K3s Documentation: https://docs.k3s.io
- Kubernetes Basics: https://kubernetes.io/docs/tutorials/
- Helm Guide: https://helm.sh/docs/intro/quickstart/
- Docker Basics: https://docs.docker.com/get-started/
Common Questions
Q: How much will this cost monthly? A: About $150/month for 3 servers that can handle 500+ clients
Q: How long to set everything up? A: 3-5 days for basic setup, 2 weeks for full migration
Q: What if something breaks? A: K3s self-heals. If pod crashes, it restarts automatically
Q: Can I rollback if update fails?
A: Yes, one command: helm rollback CLIENT_NAME
Q: How to add more servers? A: Just run K3s worker install on new server
🎯 Final Summary
What You Get
✅ Automatic Everything
- Client creation: 3 hours → 5 minutes
- Deployment: Manual → Automatic
- Scaling: Manual → Automatic
- Healing: Manual → Automatic
✅ Cost Savings
- Current: $1,500/month labor
- New: $150/month servers
- Savings: $1,350/month
✅ Features
- Every commit builds automatically
- Deploy any version to any client
- Auto-scaling (1-10 pods)
- Self-healing
- Zero-downtime updates
- One-click rollback
- Dashboard control
Start Tomorrow Morning!
- Get your Ubuntu server ready
- Copy the installation script
- Run it step by step
- Follow this guide exactly
- Ask for help when stuck
You can do this! The guide has everything you need. Good luck! 🚀
