- Hardware: Odroid N2 (4 GB RAM, ARM64)
- OS: Ubuntu Server 24.04 (aarch64)
- Reverse proxy: nginx + certbot (HTTPS)
- Init system: systemd
- URL:
https://yourdomain.com/myapps
Release binaries are cross-compiled for aarch64-unknown-linux-gnu using
cross plus sccache. Each merge to
main triggers GitHub Actions to bump the version, create a GitHub Release
with a tarball (myapps-<tag>-aarch64.tar.gz) containing the binary and
static/ folder, and deploy it to staging then production.
The same pipeline runs locally from any x86_64 dev machine with Docker:
make build-arm64 produces the aarch64 binary, make deploy-stage packages
and ships it to the Odroid via deploy.sh release-deploy. As a fallback,
deploy.sh deploy can still rsync source and build natively on the Odroid.
- SSH access to the Odroid via the
deployuser (key-based auth) rsync- For local cross-compilation (recommended over on-device build):
Docker (or Podman with
CROSS_CONTAINER_ENGINE=podman),cross(cargo install cross --git https://github.com/cross-rs/cross), andsccache.make build-arm64self-bootstraps a musl-static sccache binary into~/.cache/cross-tools/for use inside the cross container.
- nginx installed and running
- whisper.cpp + ffmpeg (for VoiceToText) β see whisper.cpp section below
- llama.cpp server (for Command Bar) β see llama.cpp section below
All deployments (both manual and CI/CD) use a dedicated deploy user. This
keeps the Rust toolchain, build cache, and sudo permissions in one place, and
limits the blast radius of the SSH key stored in GitHub Secrets.
# On the Odroid β create the user
sudo useradd --system --create-home --shell /bin/bash deploy
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chown deploy:deploy /home/deploy/.sshInstall the Rust toolchain and sccache:
sudo -u deploy bash -c 'curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y'
sudo -u deploy bash -c 'source ~/.cargo/env && cargo install sccache --locked'Grant only the sudo commands that deploy.sh needs:
sudo visudo -f /etc/sudoers.d/deploydeploy ALL=(ALL) NOPASSWD: \
/usr/bin/systemctl restart myapps, \
/usr/bin/systemctl restart myapps-stage, \
/usr/bin/systemctl --no-pager status myapps, \
/usr/bin/systemctl --no-pager status myapps-stage, \
/usr/bin/cp *, \
/usr/bin/mv *, \
/usr/bin/chown *, \
/usr/bin/chmod *, \
/usr/bin/rsync *, \
/usr/bin/sudo -u myapps *
Generate a key pair and authorize it:
# On your dev machine
ssh-keygen -t ed25519 -C "myapps-deploy" -f ~/.ssh/myapps_deploy_key -N ""
# Copy the public key to the server (deploy user has no password, so use your
# existing sudo-capable user to place it)
cat ~/.ssh/myapps_deploy_key.pub | ssh youruser@odroid.local \
'sudo tee /home/deploy/.ssh/authorized_keys > /dev/null && sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys && sudo chmod 600 /home/deploy/.ssh/authorized_keys'Configure your local SSH to use this key (add to ~/.ssh/config):
Host odroid-deploy
HostName odroid.local
User deploy
IdentityFile ~/.ssh/myapps_deploy_key
Set DEPLOY_SERVER=odroid-deploy in your deploy/*.env files.
Upload the SSH key and known hosts to GitHub for CI/CD:
gh secret set SSH_PRIVATE_KEY < ~/.ssh/myapps_deploy_key
ssh-keyscan odroid.local | gh secret set SSH_KNOWN_HOSTS
# Create GitHub environments and set all variables from deploy/*.env
make gh-envmake gh-env reads each deploy/*.env file, creates the GitHub environment
(from DEPLOY_GH_ENVIRONMENT), and sets all non-empty variables. Empty values
are skipped β GitHub doesn't allow empty environment variables. It also asserts
that DEPLOY_REMOTE_BUILD_DIR is identical across all environments (required
because the CD pipeline builds once on staging and reuses the binary for
production).
# 1. Set up the deploy user on the server (see "Deploy user setup" above)
# 2. Set DEPLOY_SERVER in your deploy/*.env files (e.g. odroid-deploy)
# 3. First-time server setup (creates myapps user, dirs, systemd, cron, nginx)
./deploy.sh prod setup
# 4. SSH into the server and edit /opt/myapps/.env with your values
# 5. Set up HTTPS on the server
ssh odroid-deploy 'sudo apt install python3-certbot-nginx && sudo certbot --nginx -d yourdomain.com'
# 6. Sync source, build on server, install, and start the service
./deploy.sh prod deploy
# 7. Create your first user (option A: invite link β user picks their own password)
ssh odroid-deploy 'sudo -u myapps /opt/myapps/myapps invite'
# Share the printed URL with the user
# 7. Create your first user (option B: direct β you choose the password)
ssh odroid-deploy 'sudo -u myapps /opt/myapps/myapps create-user --username yourname --password yourpass'Usage: ./deploy.sh <env> <command>
| Command | Description |
|---|---|
release-deploy <dir> |
Upload pre-built binary + static from a local directory, restart (used by CD and make deploy-stage) |
setup |
First-time server provisioning |
deploy |
Rsync source + build on server + install + restart |
install |
Rsync source + install + restart (skip build) |
build |
Rsync source + build on server (no install) |
restart |
Restart the service |
logs |
Tail the service logs (journalctl) |
status |
Show service status |
Outside CI, deploy.sh multiplexes SSH (one TCP connection shared across the
many ssh/scp/rsync calls) via ControlMaster=auto to avoid tripping sshd
MaxStartups / fail2ban during a deploy. CI keeps a single connection per
step and skips multiplexing.
The release-deploy command is used by the CD pipeline β it takes a directory
(extracted from the release tarball) containing the binary and static/ folder,
and copies them to the target directory (DEPLOY_REMOTE_DIR) via SCP/rsync,
without needing a build directory on the server. The deploy and install
commands are kept for local manual deploys.
Available environments are defined by config files in deploy/:
| Environment | Config file | URL | Port |
|---|---|---|---|
prod |
deploy/prod.env |
https://yourdomain.com |
3000 |
stage |
deploy/stage.env |
https://stage.yourdomain.com |
3001 |
The SSH target is set via DEPLOY_SERVER in each deploy/*.env file
(e.g. odroid-deploy matching your SSH config alias).
GitHub Actions Odroid N2
ββββββββββββββ βββββββββ
push to main
β
ββ bump version in Cargo.toml
ββ commit + tag (v0.2.0)
ββ cross build --target aarch64
ββ package tarball (binary + static/)
ββ create GitHub Release
β
ββ [deploy-stage]
β ββ gh release download tarball
β ββ extract + scp binary + static βββΈ /opt/myapps-stage/
β ββ ssh: restart
β ββ smoke test /login β 200
β
ββ [deploy-prod]
ββ gh release download tarball
ββ extract + scp binary + static βββΈ /opt/myapps/
ββ ssh: restart
ββ smoke test /login β 200
Preferred path β local cross-compile, ship binary:
Dev machine Odroid N2
βββββββββββ βββββββββ
make deploy-stage (or deploy-prod)
β
ββ cross build --target aarch64 (Docker + sccache cache)
ββ package release-pkg/ (binary + static/)
β
ββ scp binary βββββββββββββββββββΈ /opt/myapps-stage/myapps
ββ rsync static ββββββββββββββββββΈ /opt/myapps-stage/static/
ββ ssh: sudo systemctl restart ββ service running
β
ββ done
Fallback β rsync source, build on the Odroid (slow, kept for emergencies):
Dev machine Odroid N2
βββββββββββ βββββββββ
./deploy.sh prod deploy
β
ββ rsync source βββββββββββββββΈ ~/myapps-build/
β β
β ssh: cargo build --release ββ compile natively
β β
β ssh: sudo cp binary ββ /opt/myapps/myapps
β β
β ssh: sudo systemctl restart ββ service running
β
ββ done
Run once on a fresh server. It:
- Installs the Rust toolchain (if not already present)
- Installs build dependencies (
pkg-config,libssl-dev) andsccache - Creates a
myappssystem user (no login shell) - Creates
$DEPLOY_REMOTE_DIR/{data,logs,static}with proper ownership - Creates
$DEPLOY_REMOTE_DIR/.envtemplate (chmod 600) - Installs the systemd unit for the environment
- Installs a cron job for daily scheduled tasks at 06:00 (if
DEPLOY_CRON_ENABLED=true) - Installs an nginx site config for the configured domain
After setup, enable HTTPS with certbot (see Quick Start step 4).
/opt/myapps/ # Runtime (owned by myapps user)
βββ myapps # Binary
βββ .env # Environment variables (chmod 600)
βββ private.pem # Enable Banking RSA private key (chmod 600)
βββ data/
β βββ myapps.db # SQLite database (created on first run)
βββ static/ # (reserved for future use)
~/myapps-build/ # Build directory (owned by deploy user)
βββ src/
βββ Cargo.toml
βββ Cargo.lock
βββ target/ # Compilation artifacts (cached between deploys)
The build directory (~/myapps-build) is separate from the runtime directory
(/opt/myapps). Cargo's target/ is cached on the server, so subsequent
builds are incremental and fast.
File: /opt/myapps/.env
DATABASE_URL=sqlite:///opt/myapps/data/myapps.db
BASE_URL=https://yourdomain.com # Public URL
ENCRYPTION_KEY= # 32-byte hex (openssl rand -hex 32)
VAPID_PRIVATE_KEY= # base64url-encoded EC private key
VAPID_PUBLIC_KEY= # base64url-encoded uncompressed public key
VAPID_SUBJECT=mailto:you@example.com # VAPID subject claim
WHISPER_CLI_PATH=/opt/whisper.cpp/build/bin/whisper-cli # whisper.cpp binary
WHISPER_MODELS_DIR=/opt/whisper.cpp/models # GGML model directory
LLAMA_SERVER_URL= # llama.cpp server URL (optional)
BIND_ADDR=127.0.0.1:3000
DEPLOY_APPS= # Comma-separated app keys (blank = all)
AUTH_SSO_HEADER= # Trusted SSO header (e.g. Remote-User for Authelia)
EXTERNAL_APPS= # External app shortcuts (key|name|desc|icon|url;...)
SEED=false # Auto-seed on invite registration (true/false)
CLEANUP_INACTIVE_DAYS=0 # Delete inactive users after N days (0 = off)LLAMA_SERVER_URL enables the command bar (natural language command entry).
When set, myapps sends requests to a running llama.cpp server
(llama-server --port 8081 -m model.gguf). When empty the command bar is hidden.
Only DATABASE_URL and BIND_ADDR are required to start the server.
DEPLOY_APPS limits which apps are mounted and shown in the launcher. Valid
keys: leanfin, mindflow, voice_to_text, form_input, notes. When
empty or unset, all apps are available.
AUTH_SSO_HEADER enables reverse-proxy SSO authentication (e.g. Authelia). When
set to the header name that carries the authenticated username (typically
Remote-User), myapps trusts that header and auto-creates users on first visit.
The login page is bypassed. When empty, only username/password login is used.
Ensure your reverse proxy strips client-sent values for this header before
setting the authenticated value.
EXTERNAL_APPS adds shortcut tiles to the launcher that open external services
in a new tab. Format: key|name|description|icon|url entries separated by ;.
Example: vault|Vaultwarden|Password manager|π|https://vault.example.com.
BASE_URL is the public URL of the application. ENCRYPTION_KEY is needed for
storing Enable Banking credentials (per-user encrypted settings).
Installed at /etc/systemd/system/myapps.service by setup.
sudo systemctl enable myapps # auto-start on boot
sudo systemctl start myapps
sudo systemctl status myapps
sudo journalctl -u myapps -f # tail logsInstalled at /etc/cron.d/myapps by setup. Runs daily at 06:00:
0 6 * * * myapps /opt/myapps/myapps cron
The app uses the standard Web Push API with VAPID authentication for browser push notifications. No separate notification service is needed β the app sends push messages directly to browser push endpoints.
# On the server (or locally)
/opt/myapps/myapps generate-vapid-keysThis prints a key pair. Add the output to /opt/myapps/.env:
VAPID_PRIVATE_KEY=<generated private key>
VAPID_PUBLIC_KEY=<generated public key>
VAPID_SUBJECT=mailto:you@example.comRestart the service after updating .env.
Open the app in a browser, navigate to the launcher page, and click "Enable notifications". The browser will prompt for permission. Once granted, the subscription is stored in the database and push notifications will be delivered to that browser.
- Desktop: Chrome, Firefox, Edge, Safari 16+
- Android: Chrome (including installed PWA)
- iOS: Safari 16.4+ (requires the app to be installed as a PWA via "Add to Home Screen")
whisper.cpp is the speech-to-text engine used by the VoiceToText app. It runs entirely on CPU using ARM NEON SIMD β no GPU or NPU required.
sudo apt install -y build-essential cmake ffmpegffmpeg is needed to convert uploaded audio to the 16 kHz mono WAV format that whisper.cpp expects.
cd /opt
sudo git clone https://github.com/ggml-org/whisper.cpp.git
cd whisper.cpp
sudo cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
sudo cmake --build build -j4The binary will be at /opt/whisper.cpp/build/bin/whisper-cli.
cd /opt/whisper.cpp
# Base model (recommended β good accuracy, ~1-2 min per minute of audio)
sudo ./models/download-ggml-model.sh base
# Tiny model (optional β faster, less accurate, ~30-60s per minute of audio)
sudo ./models/download-ggml-model.sh tinyModel sizes on disk: tiny ~75 MB, base ~142 MB. At runtime they use roughly 2x their disk size in RAM.
Add to /opt/myapps/.env:
WHISPER_CLI_PATH=/opt/whisper.cpp/build/bin/whisper-cli
WHISPER_MODELS_DIR=/opt/whisper.cpp/modelsBoth have defaults (whisper-cli and models respectively), so if you symlink
the binary into $PATH and keep models in a models/ directory relative to the
working dir, you can skip these.
# Test transcription with a sample file
/opt/whisper.cpp/build/bin/whisper-cli \
-m /opt/whisper.cpp/models/ggml-base.bin \
-f /opt/whisper.cpp/samples/jfk.wav \
--no-timestamps| Model | RAM at runtime | ~Time per 1 min audio | Notes |
|---|---|---|---|
| tiny | ~200 MB | 30β60s | Near real-time |
| base | ~400 MB | 60β120s | Recommended for async use |
| small | ~1.2 GB | 3β5 min | Feasible but slow |
The background worker processes one job at a time to avoid memory pressure. With 4 GB RAM, tiny and base fit comfortably alongside the Axum server.
llama.cpp powers the natural-language command bar. It runs as a persistent HTTP server so the model stays loaded in memory between requests.
sudo apt install -y build-essential cmakecd /opt
sudo git clone https://github.com/ggml-org/llama.cpp.git
cd llama.cpp
sudo cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
sudo cmake --build build -j4The server binary will be at /opt/llama.cpp/build/bin/llama-server.
Any small instruction-tuned GGUF model works. Qwen2.5-1.5B-Instruct is recommended β it's a pure transformer where all layers use KV cache, enabling effective prompt prefix caching. Hybrid models like Qwen3.5 use SSM layers that must re-evaluate the full sequence on every request, making caching ineffective.
sudo mkdir -p /opt/llama.cpp/models
cd /opt/llama.cpp/models
sudo wget https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q5_k_m.ggufModel size: ~1.2 GB on disk, ~1.5 GB RAM at runtime. With whisper base loaded, total memory use stays under 3 GB. Qwen2.5 uses ChatML natively, matching how MyApps constructs prompts.
sudo tee /etc/systemd/system/llama-server.service > /dev/null <<'SERVICE'
[Unit]
Description=llama.cpp inference server
After=network.target
[Service]
Type=simple
ExecStart=/opt/llama.cpp/build/bin/llama-server \
--host 127.0.0.1 \
--port 8081 \
-m /opt/llama.cpp/models/qwen2.5-1.5b-instruct-q5_k_m.gguf \
-c 2048 \
--parallel 1
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
SERVICE
sudo systemctl daemon-reload
sudo systemctl enable llama-server
sudo systemctl start llama-serverAdd to /opt/myapps/.env:
LLAMA_SERVER_URL=http://127.0.0.1:8081Restart myapps after updating .env. The command bar will appear at the bottom
of every page.
# Check the server is running
curl http://127.0.0.1:8081/health
# Test a completion
curl http://127.0.0.1:8081/completion \
-H "Content-Type: application/json" \
-d '{"prompt":"Say hello","cache_prompt":true,"id_slot":0,"n_predict":32}'| Model | RAM | ~Inference time | Notes |
|---|---|---|---|
| Qwen2.5-1.5B-Instruct Q5_K_M | ~1.5 GB | 1β7s (cached) | Recommended β pure transformer, cache-friendly |
| Qwen3.5-2B Q4_K_M | ~1.5 GB | 3β6s | Hybrid SSM, limited cache benefit |
| SmolLM3-3B Q4_K_M | ~2.0 GB | 4β8s | More capable, higher RAM |
| Gemma 3 1B-it Q4_K_M | ~0.7 GB | 1β3s | Fastest, less accurate |
The server processes one request at a time. MyApps uses a mutex to serialize command requests so the server is never overloaded.
Merging to main triggers automatic deployment via .github/workflows/cd.yml:
push to main
β
βΌ
[release] βββ auto-bump version, cross-compile aarch64, package tarball, create GitHub Release
β
βΌ
[deploy-stage] βββ download release tarball, extract, upload to server, install + restart
β smoke test /login β 200
βΌ
[deploy-prod] βββ download same release tarball, extract, upload to server, install + restart
β smoke test /login β 200
βΌ
Done
The version in Cargo.toml is bumped during development as part of the
/finish-development workflow, before the PR is opened. Bump type is
determined by the branch name and commit prefixes:
| Prefix | Bump | Example |
|---|---|---|
[FEAT-*] |
minor | [FEAT-42] Add new dashboard |
[BREAKING-*] |
major | [BREAKING] Remove legacy API |
| anything else | patch | [BUG-99] Fix login redirect |
Makefile targets are available for manual use: make bump-patch,
make bump-minor, make bump-major.
When merged to main, the CD pipeline reads the version from Cargo.toml,
creates a git tag (v0.2.0), and publishes a GitHub Release with a tarball
containing the binary and static assets. If the tag already exists (e.g.
re-running the workflow), the release step is skipped and the existing
release tarball is deployed.
CI (format, clippy, tests) runs separately via ci.yml. The CD pipeline
trusts that CI has already passed on main.
The CD workflow requires two GitHub Environments (staging and
production), each with the following configuration:
Secrets (repo-level or per-environment):
| Secret | Description |
|---|---|
SSH_PRIVATE_KEY |
Ed25519 private key authorized on the server |
SSH_KNOWN_HOSTS |
Output of ssh-keyscan -p <port> <server-host> |
Environment variables (per GitHub Environment):
| Variable | Example (staging) | Example (production) |
|---|---|---|
DEPLOY_SERVER |
deploy@odroid.local |
deploy@odroid.local |
DEPLOY_SSH_PORT |
22 |
22 |
DEPLOY_DOMAIN |
stage.yourdomain.com |
yourdomain.com |
DEPLOY_REMOTE_DIR |
/opt/myapps-stage |
/opt/myapps |
DEPLOY_REMOTE_BUILD_DIR |
~/myapps-stage-build |
~/myapps-stage-build |
DEPLOY_SERVICE_NAME |
myapps-stage |
myapps |
DEPLOY_NGINX_SITE |
myapps-stage |
myapps |
DEPLOY_PORT |
3001 |
3000 |
DEPLOY_CRON_ENABLED |
false |
true |
DEPLOY_ICON |
icon-stage.svg |
icon.svg |
DEPLOY_SEED |
true |
false |
These match the values in deploy/*.env.example.
The same deploy user is used for both manual and CI/CD deploys. The
DEPLOY_CI=true flag tells deploy.sh to skip -t (TTY allocation) since
CI runners have no interactive terminal.
The CD workflow supports workflow_dispatch, so you can trigger a deploy
manually from the GitHub Actions UI without pushing a commit. Manual runs
deploy to staging by default; tick the Also deploy to production input
to continue on to prod after staging.
The setup command installs an HTTP-only nginx config at
/etc/nginx/sites-available/myapps with server_name set to your domain.
The config proxies all requests to 127.0.0.1:3000.
To enable HTTPS:
sudo apt install python3-certbot-nginx
sudo certbot --nginx -d yourdomain.comCertbot will modify the nginx config to add listen 443 ssl with the
certificate paths and redirect HTTP to HTTPS automatically.
The app is then accessible at https://yourdomain.com.
A staging instance runs alongside production on the same Odroid, at
https://stage.yourdomain.com. It uses a separate database, systemd
service, and nginx site, listening on port 3001.
All environment-specific values live in deploy/*.env. The deploy script is
environment-agnostic β it sources the config file matching the first argument.
To add a new environment (e.g. demo), create deploy/demo.env with the
appropriate values.
# 1. First-time setup (creates dirs, systemd, nginx on the server)
./deploy.sh stage setup
# 2. Edit /opt/myapps-stage/.env on the server with appropriate values
# 3. DNS: add stage.yourdomain.com to your DNS provider
# 4. HTTPS
ssh odroid-deploy 'sudo apt install python3-certbot-nginx && sudo certbot --nginx -d stage.yourdomain.com'
# 5. Deploy
./deploy.sh stage deploy
# 6. Create a user (invite link or direct)
ssh odroid-deploy 'sudo -u myapps /opt/myapps-stage/myapps invite'
# Or: ssh odroid-deploy 'sudo -u myapps /opt/myapps-stage/myapps create-user --username yourname --password yourpass'When SEED=true is set in the server's .env, new users who register via an
invite link will automatically get demo data seeded for all deployed apps.
When CLEANUP_INACTIVE_DAYS is set (e.g. 7), inactive users are
automatically cleaned up on each service start (i.e. on every deploy, since
the service restarts). You can also run it manually:
cargo run -- cleanup-users --days 7/opt/myapps-stage/ # Runtime (owned by myapps user)
βββ myapps # Binary
βββ .env # Environment variables (chmod 600)
βββ data/
β βββ myapps.db # SQLite database (separate from prod)
βββ logs/
βββ static/
~/myapps-stage-build/ # Build directory (owned by deploy user)