Radical Website Self-Hosting

Your phone is already a server. It just doesn’t know it yet.
A modern mid-range Android phone has 4–8 GB RAM, 64–256 GB storage, an ARM64 CPU, and always-on connectivity. With Termux (a full Linux environment on Android) and a Cloudflare Tunnel (bypasses NAT and port restrictions), you can run a complete web stack that is publicly accessible — without a static IP, without opening router ports, without a VPS.
The total cost: a domain (~10€/year) and a Cloudflare account (free tier).
The stack#
| Software | Role |
|---|---|
| cloudflared | Cloudflare Tunnel — public HTTPS access |
| Hugo | Static website server |
| Copyparty | File storage / cloud drive |
| GoToSocial | Fediverse (ActivityPub) instance |
| Mox | Self-hosted mail server |
| Listmonk | Newsletter manager |
| PostgreSQL | Database |
| sshd | SSH access |
All services bind to 127.0.0.1. The phone never needs an open port — public access goes exclusively through the Cloudflare Tunnel.
1. Hardware requirements#
| Component | Minimum | Notes |
|---|---|---|
| RAM | 3 GB | 5+ GB recommended |
| Internal storage | 32 GB free | more is better |
| External SD | optional | useful for backups |
| OS | Android 10+ | LineageOS works well |
Keep the phone plugged in. It acts as a 24/7 server.
2. Required apps#
Install from F-Droid — not Google Play, which ships outdated Termux builds:
- Termux — Linux shell environment
- Termux:Boot — auto-start scripts on reboot
- Termux:API — Android API access from shell
Install F-Droid first: https://f-droid.org
Then install base packages:
pkg update && pkg upgrade
pkg install git curl wget python openssh nano termux-api
3. Cloudflare Tunnel — public access without port forwarding#
A Cloudflare Tunnel creates an encrypted outbound connection from your phone to Cloudflare’s edge. Visitors hit Cloudflare’s servers, which forward traffic to your phone. No open ports, no static IP needed.
Install cloudflared#
# Download the ARM64 binary from:
# https://github.com/cloudflare/cloudflared/releases
# Look for: cloudflared-linux-arm64
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64 \
-O $PREFIX/bin/cloudflared
chmod +x $PREFIX/bin/cloudflared
Create a tunnel#
cloudflared tunnel login
cloudflared tunnel create my-tunnel
Configure ~/.cloudflared/config.yml#
tunnel: YOUR_TUNNEL_ID
credentials-file: /data/data/com.termux/files/home/.cloudflared/YOUR_TUNNEL_ID.json
ingress:
- hostname: files.yourdomain.com
service: http://localhost:COPYPARTY_PORT
- hostname: newsletter.yourdomain.com
service: http://localhost:LISTMONK_PORT
- hostname: www.yourdomain.com
service: http://localhost:HUGO_PORT
- hostname: social.yourdomain.com
service: http://localhost:GTS_PORT
- hostname: ssh.yourdomain.com
service: ssh://localhost:SSH_PORT
- service: http_status:404
Create DNS records#
cloudflared tunnel route dns my-tunnel www.yourdomain.com
cloudflared tunnel route dns my-tunnel files.yourdomain.com
cloudflared tunnel route dns my-tunnel newsletter.yourdomain.com
cloudflared tunnel route dns my-tunnel social.yourdomain.com
cloudflared tunnel route dns my-tunnel ssh.yourdomain.com
Start#
nohup cloudflared tunnel run my-tunnel > ~/logs/cloudflared.log 2>&1 &
4. Hugo — static website#
# https://github.com/gohugoio/hugo/releases
# Look for: hugo_extended_X.X.X_linux-arm64.tar.gz
tar -xzf hugo_extended_*_linux-arm64.tar.gz
mv hugo $PREFIX/bin/ && chmod +x $PREFIX/bin/hugo
hugo new site ~/my-site
cd ~/my-site
git init
git submodule add https://github.com/panr/hugo-theme-terminal themes/terminal
hugo.toml:
baseURL = "https://www.yourdomain.com/"
languageCode = "en-us"
title = "My Site"
theme = "terminal"
minify = true
Start the server:
cd ~/my-site && nohup hugo server \
--bind 127.0.0.1 \
--baseURL "https://www.yourdomain.com/" \
--appendPort=false \
> ~/logs/hugo/hugo.log 2>&1 &
--bind 127.0.0.1is critical — Hugo must only be reachable through the tunnel, not directly.
5. Copyparty — file storage#
Copyparty turns a folder into a web-accessible file manager with upload, streaming, and search.
pip install copyparty
Create ~/config/copyparty.conf:
[/]
/home/YOUR_USERNAME/drive r *
[/private]
/home/YOUR_USERNAME/drive/private rw myuser mypassword
nohup copyparty -c ~/config/copyparty.conf > ~/logs/copyparty.log 2>&1 &
6. PostgreSQL — database#
Required by GoToSocial and Listmonk.
pkg install postgresql
initdb $PREFIX/var/lib/postgresql
pg_ctl -D $PREFIX/var/lib/postgresql start
Optimized config for Android#
Edit $PREFIX/var/lib/postgresql/postgresql.conf. The defaults are tuned for desktops — these are safer for a phone:
shared_buffers = 128MB
effective_cache_size = 512MB
work_mem = 8MB
maintenance_work_mem = 32MB
max_connections = 50
checkpoint_completion_target = 0.9
wal_buffers = 8MB
min_wal_size = 80MB
max_wal_size = 256MB
synchronous_commit = off
Do not use the standard “15% of RAM” formula for
shared_buffers— it’s too high on Android.synchronous_commit = offimproves performance; worst case is losing 1 transaction on a crash.
createdb gotosocial
createdb listmonk
7. GoToSocial — fediverse instance#
GoToSocial is a lightweight ActivityPub server (Mastodon-compatible). It lets you run your own fediverse account at @[email protected].
# https://github.com/superseriousbusiness/gotosocial/releases
# Look for: gotosocial_X.X.X_linux_armv8.tar.gz
mkdir ~/gotosocial
tar -xzf gotosocial_*_linux_armv8.tar.gz -C ~/gotosocial
Key settings in ~/gotosocial/config.yaml:
host: "social.yourdomain.com"
db-type: "postgres"
db-address: "127.0.0.1"
db-port: POSTGRES_PORT
db-database: "gotosocial"
port: GTS_PORT
bind-address: "127.0.0.1"
# Memory optimization for Android
advanced-sender-multiplier: 1
db-max-open-conns-multiplier: 4
Start:
cd ~/gotosocial && GODEBUG=netdns=go GOMEMLIMIT=384MiB \
nohup ./gotosocial-cgo \
--config-path config.yaml \
--web-template-base-dir ~/gotosocial/web/template/ \
--web-asset-base-dir ~/gotosocial/web/assets/ \
server start \
>> ~/logs/gotosocial.log 2>&1 &
GOMEMLIMIT=384MiBis required. Without it, GoToSocial crashes every ~20 minutes because it tries to read cgroup memory limits that don’t exist on Android (automemlimit behavior). This flag disables it.GODEBUG=netdns=goforces Go’s built-in DNS resolver — the system one can hang on Android.
Create your admin account:
cd ~/gotosocial
./gotosocial-cgo --config-path config.yaml admin account create \
--username yourusername \
--email [email protected] \
--password "your-strong-password"
./gotosocial-cgo --config-path config.yaml admin account promote \
--username yourusername
8. Mox — mail server#
Mox is a modern all-in-one mail server. It works for low-volume transactional email, with one hard limitation: mobile IPs have no PTR record, so Gmail blocks direct delivery. Use a free SMTP relay (Brevo — 300 emails/day) as a workaround.
Important: Mox must be compiled from source with Termux-specific patches. Pre-built binaries won’t work on Android.
pkg install golang
git clone https://github.com/mjl-/mox ~/mox-src
cd ~/mox-src
# Apply Termux patches (setgid/setuid, chroot, file descriptor limits)
# Search the mox issue tracker or Termux community for current patches
go build -o ~/mox .
Initialize:
mkdir ~/mox-data && cd ~/mox-data
../mox quickstart [email protected]
⚠️ Mox config files use TAB indentation. Always edit with
printf, never with a text editor or heredoc. Verify withcat -A.
DNS records needed:
| Record | Type | Value |
|---|---|---|
mail.yourdomain.com | A | your current public IP |
yourdomain.com | MX | mail.yourdomain.com |
yourdomain.com | TXT | v=spf1 a:mail.yourdomain.com ~all |
_dmarc.yourdomain.com | TXT | v=DMARC1; p=none; rua=mailto:[email protected] |
| DKIM selectors | TXT | generated by mox — copy from mox-data/data/dkim/ |
9. Listmonk — newsletter#
# https://github.com/knadh/listmonk/releases
mkdir ~/listmonk-app
# extract binary to ~/listmonk-app/
cd ~/listmonk-app && ./listmonk --install
SMTP settings (connect to Mox locally):
| Field | Value |
|---|---|
| Host | 127.0.0.1 |
| Port | SMTP_LOCAL_PORT |
| Auth Protocol | Plain |
| Username | [email protected] |
| TLS | Off |
| HELO hostname | mail.yourdomain.com |
Subscription form for Hugo:
<form method="post" action="https://newsletter.yourdomain.com/subscription/form?page=done">
<input type="hidden" name="nonce" />
<input type="hidden" name="redirect" value="https://www.yourdomain.com/welcome/" />
<p><input type="email" name="email" required placeholder="email" /></p>
<p><input type="text" name="name" placeholder="name (optional)" /></p>
<input type="checkbox" name="l" checked value="YOUR_LIST_UUID" style="display:none" />
<input type="submit" value="subscribe" />
</form>
10. Auto-start, watchdog, and backup#
Auto-start on boot#
Install Termux:Boot from F-Droid and open it once to register the boot receiver.
Create ~/.termux/boot/start.sh:
#!/data/data/com.termux/files/usr/bin/bash
sleep 10
termux-wake-lock
crond
~/gestione/start.sh
termux-wake-lockprevents Android from killing Termux when the screen turns off.
Watchdog (cron every 5 minutes)#
A watchdog script checks whether services are running and restarts them. Key design decisions that matter:
- Lockfile — prevents parallel instances, which caused PostgreSQL cascade crashes
- psql timeout — avoids infinite hang when GoToSocial saturates DB connections
- Stale PID cleanup — removes
postmaster.pidif the PostgreSQL process is gone - Network-aware — skips network-dependent services when offline; always checks sshd
crontab -e
# Add:
*/5 * * * * ~/gestione/watchdog.sh
0 3 * * 0 ~/gestione/logrotate.sh
Backup#
tar -czf backup.tar.gz \
--exclude="$HOME/.cache" \
--exclude="$HOME/my-site/public" \
--exclude="$HOME/my-site/resources" \
-C /data/data/com.termux/files home
Back up these separately to external storage — they cannot be regenerated:
~/mox-data/data/dkim/— DKIM private keys~/.cf-credentials— Cloudflare API credentials
11. SSH access#
Termux’s SSH runs on a non-standard port (configurable in $PREFIX/etc/ssh/sshd_config).
pkg install openssh && passwd && sshd
Connect from local network:
ssh -p SSH_PORT $(whoami)@PHONE_LOCAL_IP
Connect from anywhere via Cloudflare Tunnel:
ssh -o ProxyCommand="cloudflared access ssh --hostname ssh.yourdomain.com" \
$(whoami)@ssh.yourdomain.com
12. Dynamic DNS for the mail server#
Your phone’s public IP changes when you switch networks. The mail server’s A record must stay updated for SPF to pass.
~/update-dns.sh calls the Cloudflare API to update the record on every startup. Credentials are stored in ~/.cf-credentials (chmod 600).
CURRENT_IP=$(curl -s ifconfig.me)
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID_A" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$MAIL_HOSTNAME\",\"content\":\"$CURRENT_IP\",\"ttl\":60,\"proxied\":false}"
13. Security checklist#
~/.cf-credentials→chmod 600- Cloudflare API token with DNS-edit-only permissions
~/mox-data/data/dkim/with restricted permissions- DKIM keys backed up to external storage
- All services bind to
127.0.0.1, not0.0.0.0 - Disable SSH password auth — use keys only (
$PREFIX/etc/ssh/sshd_config) - Phone locked with strong PIN
Quick diagnostics#
# Check running services
ps aux | grep -E "cloudflared|hugo|copyparty|sshd|mox|listmonk|gotosocial|postgres"
# Live logs
tail -f ~/logs/hugo/hugo.log
tail -f ~/logs/cloudflared.log
tail -f ~/logs/gotosocial.log
tail -f ~/logs/watchdog.log
# Current public IP
curl -s ifconfig.me && echo
# Disk space
df -h | grep -E "data|storage"
# Cloudflare Tunnel status
cloudflared tunnel info my-tunnel
Appendix: tested versions#
| Software | Version | Notes |
|---|---|---|
| cloudflared | 2025-10-19 | ARM64 binary |
| Hugo | v0.156.0+extended | android/arm64 |
| Copyparty | latest via pip | |
| Mox | v0.0.15+ | compiled from source with Termux patches |
| Listmonk | v6.0.0 | |
| GoToSocial | latest ARM64 | -cgo variant required |
| PostgreSQL | 18.x | via pkg install postgresql |
This stack runs entirely on an Android phone. It is radical, fragile in some ways, and completely yours.