A student-level cloud service learning project for building a small but complete chat backend with Go, WebSocket, PostgreSQL, Redis, Docker Compose, and Nginx.
This is not positioned as a production-ready IM system, and it intentionally does not focus on complex security hardening yet. The goal is to understand the full path from local development to deployment on a cloud server, while practicing common backend and database operations: HTTP APIs, authentication, WebSocket connections, online state, cross-instance message delivery, offline message storage, containerized deployment, and a unified reverse-proxy entry point.
Current learning scope:
- Build and run a small microservice-style system on one cloud server
- Use Docker Compose to orchestrate the application, PostgreSQL, Redis, and Nginx
- Learn basic PostgreSQL table design, initialization, inserts, queries, and deletion of offline messages
- Use Redis for online state and Pub/Sub between service instances
- Use GitHub Actions for CI testing
- Add CD next, so code pushed to GitHub can be deployed to the cloud server in a repeatable way
Chinese version: README.zh-CN.md
- User registration and login
- bcrypt password hashing
- JWT-based login sessions and WebSocket authentication
- WebSocket long-lived connections
- One-to-one chat messages
- Online user list for the current server instance
- Cross-instance message delivery through Redis Pub/Sub
- Offline message storage and retrieval through PostgreSQL
- WebSocket ping/pong heartbeat and read/write deadlines
- Single active connection policy per user, including cross-instance kick-out
- Unified JSON responses for HTTP APIs
- Basic request and WebSocket message validation
- Go 1.25
net/httpgorilla/websocket- PostgreSQL 15
pgx/v5- Redis 7
go-redis/v9- bcrypt
- JWT
- Docker Compose
- Nginx
Browser / Client
|
v
Nginx
/ \
v v
chat-1 chat-2
\ /
Redis
|
v
PostgreSQL
Nginx exposes a single HTTP entry point on port 80 and forwards HTTP/WebSocket traffic to two chat-server instances.
Each chat-server instance manages only its local WebSocket clients. Redis stores online user state and broadcasts messages between instances. PostgreSQL stores users and offline messages.
.
|-- README.md
|-- README.zh-CN.md
|-- docker-compose.yml
|-- nginx.conf
|-- init.sql
|-- index.html
|-- go.work
|-- chat-server/
| |-- Dockerfile
| |-- go.mod
| |-- go.sum
| |-- main.go
| |-- handlers.go
| |-- hub.go
| |-- db.go
| |-- redis.go
| |-- auth.go
| |-- response.go
| |-- models.go
| `-- auth_test.go
`-- 云服开发文档v2.0.md
- Docker
- Docker Compose
docker compose up --buildAfter the services start:
- Web UI:
http://localhost/ - HTTP API entry point:
http://localhost/ - WebSocket entry point:
ws://localhost/ws?token=<jwt>
Compose starts:
postgresredischat-server-1chat-server-2nginx
docker compose downTo remove the PostgreSQL volume as well:
docker compose down -vYou can run the Go service directly from the chat-server module if PostgreSQL and Redis are available:
cd chat-server
$env:DB_URL="postgres://postgres:chat_server_dev_password@localhost:5432/chatdb?sslmode=disable"
$env:REDIS_ADDR="localhost:6379"
$env:INSTANCE_ID="server-1"
$env:JWT_SECRET="chat_server_dev_jwt_secret"
go run .On macOS/Linux, use export instead of $env:.
The service listens on :8080.
| Environment variable | Default | Description |
|---|---|---|
DB_URL |
empty | PostgreSQL connection string. Required for startup. |
REDIS_ADDR |
localhost:6379 |
Redis address. |
INSTANCE_ID |
server-1 |
Logical server instance ID used in Redis online state. |
JWT_SECRET |
dev-secret-change-me |
JWT signing secret. Use a strong shared secret for all instances. |
All HTTP responses use the same envelope:
{
"code": 0,
"message": "ok",
"data": {}
}Error responses use the HTTP status code as code and include a message.
POST /register
Content-Type: application/jsonRequest:
{
"username": "alice",
"password": "secret"
}Response:
{
"code": 0,
"message": "ok",
"data": {
"user_id": 1
}
}POST /login
Content-Type: application/jsonRequest:
{
"username": "alice",
"password": "secret"
}Response:
{
"code": 0,
"message": "ok",
"data": {
"user_id": 1,
"username": "alice",
"token": "<jwt>"
}
}Connect with a JWT:
GET /ws?token=<jwt>
Alternatively, pass the token in the request header:
Authorization: Bearer <jwt>
{
"type": "chat",
"to_user_id": 2,
"content": "hello"
}The receiver gets:
{
"type": "chat",
"to_user_id": 2,
"from_user_id": 1,
"content": "hello"
}If the receiver is offline, the message is saved in PostgreSQL.
{
"type": "get_online_list"
}Response:
{
"type": "online_list",
"data": [1, 2]
}Note: this list is returned from the current chat-server instance's in-memory hub.
{
"type": "get_offline"
}Response:
{
"type": "offline_list",
"data": [
{
"msg_id": 1,
"to_user_id": 2,
"from_user_id": 1,
"content": "hello",
"created_at": "2026-05-12T10:00:00Z"
}
]
}Offline messages are deleted after they are fetched.
{
"type": "error",
"content": "error message"
}init.sql creates:
users: user account datafriends: reserved friend relationship tableoffline_messages: messages waiting for offline users
Tests are configured to run in GitHub Actions instead of being run locally by default.
The workflow is defined in .github/workflows/go-tests.yml and runs go test ./... from the chat-server module on pushes, pull requests, and manual workflow dispatches.
Current tests cover basic JWT generation, parsing, and invalid token rejection.
The project can already be deployed manually on a cloud server with Docker Compose:
git pull
docker compose up -d --buildCD is the next missing step. For this learning project, the recommended simple path is:
- Keep the project repository on the cloud server.
- Let GitHub Actions run tests first.
- After tests pass, use a GitHub Actions deployment job to SSH into the cloud server.
- Run
git pullanddocker compose up -d --buildon the server.
This is easier to understand than introducing an image registry immediately. Avoid making the cloud server continuously poll GitHub and pull automatically. A server-side pull is acceptable as the deployment command, but it should be triggered by CI/CD, or run manually, so deployment timing and logs are visible.
For a more standard later version, build a Docker image in GitHub Actions, push it to a registry, and let the server pull the image and restart Compose.
This repository includes .github/workflows/k3s-deploy.yml for the simple K3s CD path. Configure these GitHub repository secrets:
| Name | Description |
|---|---|
SERVER_HOST |
Cloud server public IP or domain. |
SERVER_USER |
SSH username. |
SERVER_SSH_KEY |
Private SSH key used by GitHub Actions to log in to the server. |
SERVER_PORT |
Optional SSH port. Defaults to 22 if omitted. |
SUDO_PASSWORD |
Optional sudo password. Prefer passwordless sudo for the deploy user and leave this unset. |
DEPLOY_PATH |
Optional deployment directory on the cloud server. If omitted, the workflow uses $HOME/chat-server. |
Generate a deployment key locally:
ssh-keygen -t ed25519 -C "chat-server-deploy" -f ~/.ssh/chat_server_deployInstall the public key on the server:
ssh-copy-id -i ~/.ssh/chat_server_deploy.pub <server-user>@<server-host>Add the private key content to GitHub Secrets:
cat ~/.ssh/chat_server_deployUse the output as the value of SERVER_SSH_KEY.
After key login works, disable SSH password login on the server:
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
Then restart SSH:
sudo systemctl restart sshIf the deploy user still needs a password for sudo, either configure passwordless sudo for the learning server or add SUDO_PASSWORD as a temporary GitHub Secret.
For passwordless sudo, create a sudoers file on the server:
sudo visudo -f /etc/sudoers.d/chat-server-deployAdd:
<server-user> ALL=(ALL) NOPASSWD:ALL
Then verify:
sudo -n trueIf it succeeds, GitHub Actions can deploy without storing a server password.
The workflow runs Go tests first. If tests pass, it SSHs into the server, clones or updates the repository, and runs:
bash scripts/k3s-deploy.shThis repository also includes a simple K3s deployment path for learning Kubernetes concepts with a lighter single-node cluster.
The K3s manifests are in k8s/:
postgres.yaml: PostgreSQL with a PVCredis.yaml: Redis single instancechat-server.yaml: two Go service replicas behind a Kubernetes Serviceweb.yaml: Nginx servingindex.htmlingress.yaml: K3s Traefik Ingress for/,/register,/login, and/ws- Runtime Kubernetes Secret: generated by
scripts/k3s-deploy.sh, not committed to the repository
On a fresh cloud server, clone the repository and run:
bash scripts/k3s-deploy.shThe script will:
- Install K3s if it is not already installed.
- Install Docker if it is not already installed.
- Build the local
chat-server:localimage with Docker. - Import the image into K3s containerd.
- Create a Kubernetes Secret from environment variables or learning defaults.
- Create ConfigMaps from
init.sqlandindex.html. - Apply the Kubernetes manifests.
- Wait for PostgreSQL, Redis, chat-server, and web deployments to become ready.
Prerequisites on the server:
- Linux cloud server
curlsudo, unless running as root
If the Docker Compose version is already running on the same server, stop it first because it may already be using port 80:
docker compose downAfter deployment, open:
http://<server-public-ip>/
Useful commands:
sudo k3s kubectl -n chat-server get pods
sudo k3s kubectl -n chat-server logs deploy/chat-server
sudo k3s kubectl -n chat-server describe pod <pod-name>If you already have an image in a registry, skip the local build by passing IMAGE:
IMAGE=ghcr.io/<user>/<image>:<tag> bash scripts/k3s-deploy.shIn that mode, Docker is not required on the server.
For public repositories, do not commit real server addresses, SSH usernames, passwords, kubeconfig files, or production .env files. This project keeps deployment credentials in GitHub Secrets and generates the K3s Secret at deploy time.
- This project is designed for backend engineering practice.
- The current friend table is present, but friend-related HTTP/WebSocket business flows are not fully implemented.
- The WebSocket upgrader currently allows all origins, which is convenient for local testing but should be restricted before production use.
- The default JWT secret is only suitable for local development.