K3s is a lightweight Kubernetes distribution that installs with a single command and runs comfortably on small servers. Out of the box it ships with sensible defaults: flannel as the network plugin and an embedded SQLite (or etcd) datastore. Those defaults are fine for a lab, but they have real limits. Flannel cannot enforce Kubernetes NetworkPolicy, and the embedded datastore ties your cluster state to the local disk of one node.
This tutorial replaces both defaults. You will install K3s with Calico as the CNI plugin, which gives you network policy enforcement and better scalability, and with an external MySQL database as the datastore, which means your cluster state lives outside the node and you can add or replace server nodes without ceremony.
This guide is for sysadmins and DevOps engineers who want a small but production-minded Kubernetes cluster on their own hardware. We will install a single-node cluster, but the whole point of this setup is that you can grow it to multiple servers and agents later without rebuilding anything.
Why Calico and Why an External Database
Before running commands, it is worth understanding what we are changing and why.
The CNI plugin is the component that gives every pod an IP address and moves traffic between them. K3s bundles flannel because it is small and zero-configuration. The trade-off is that flannel only does connectivity, it cannot enforce NetworkPolicy objects. If you create a policy that says “only the frontend can talk to the database”, flannel silently ignores it. Calico implements both connectivity and policy enforcement, and it scales well because it routes traffic instead of relying purely on overlays where possible.
The datastore is where Kubernetes keeps every object you create: deployments, secrets, service definitions, everything. Full Kubernetes uses etcd. K3s can use etcd too, but it also ships with kine, a shim that translates the etcd API to a regular SQL database. Point K3s at a MySQL endpoint and your entire cluster state lives in a database you already know how to back up, replicate, and monitor. It also means the K3s server node itself becomes almost stateless: if it dies, you install a new one against the same database and the cluster comes back.
The result of this tutorial: a one-node K3s cluster where flannel is disabled, Calico handles networking and network policy, and MySQL holds the cluster state.
Prerequisites
- A server with Ubuntu 20.04, 22.04, or 24.04 (2 CPU cores and 4 GB RAM is a comfortable minimum once Calico is running)
- A user with
sudoprivileges - Basic familiarity with the Linux command line and Kubernetes concepts
- No other Kubernetes or container network plugin already installed on the machine
In this tutorial MySQL runs on the same host to keep things simple. In production you would point K3s at a separate database server or a managed MySQL service, the K3s configuration is identical either way.
Step 1: Plan Your Network CIDRs
This is the step people skip, and it causes the strangest problems later. Your cluster needs a pod CIDR: the IP range Calico assigns to pods. It must not overlap with your host network.
Calico’s default pod CIDR is 192.168.0.0/16. If your servers live in a 192.168.x.x LAN (very common at home and in small offices), do not use the default. Pick something like 10.42.0.0/16 instead. We will use 10.42.0.0/16 in this tutorial because it is safe for most environments and it also happens to be the range K3s uses by default.
Write down your choice, you will need the exact same value twice: once when installing K3s and once when configuring Calico. A mismatch between the two is the most common way this setup breaks.
Step 2: Prepare the MySQL Database
Install MySQL server:
sudo apt update
sudo apt install mysql-server -y
Make sure it is running and enabled at boot:
sudo systemctl enable --now mysql
sudo systemctl status mysql
The status should show active (running):
● mysql.service - MySQL Community Server
Loaded: loaded (/lib/systemd/system/mysql.service; enabled; vendor preset: enabled)
Active: active (running)
Status: "Server is operational"
Now create a database and a dedicated user for K3s. Open the MySQL shell:
sudo mysql
Run these statements, changing the database name, username, and especially the password to your own values:
CREATE DATABASE k3s_database;
CREATE USER 'k3s_user'@'%' IDENTIFIED BY 'StrongPasswordHere';
GRANT ALL PRIVILEGES ON k3s_database.* TO 'k3s_user'@'%';
FLUSH PRIVILEGES;
EXIT;
The '%' host wildcard allows connections from any address, which you will want the moment you add a second server node. If your MySQL runs on a separate machine, also check /etc/mysql/mysql.conf.d/mysqld.cnf and make sure bind-address is not locked to 127.0.0.1, otherwise remote K3s nodes cannot reach it.
Step 3: Give the Endpoints Stable Names
We will reference the cluster registration address and the MySQL endpoint by name instead of raw IPs. This makes the cluster flexible: if the database moves, you update one DNS record or hosts entry instead of reinstalling K3s.
For a single-host lab, add both names to /etc/hosts:
sudo nano /etc/hosts
Add this line (use the server’s real IP if other nodes will join later):
127.0.0.1 k3s-endpoint mysql-endpoint
Verify both names resolve:
ping -c 2 mysql-endpoint
PING mysql-endpoint (127.0.0.1) 56(84) bytes of data.
64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.031 ms
64 bytes from localhost (127.0.0.1): icmp_seq=2 ttl=64 time=0.040 ms
In a real multi-node environment, create proper DNS records instead of hosts entries, and point k3s-endpoint at a load balancer in front of your server nodes.
Step 4: Install K3s Without Flannel, With MySQL
Now the main event. This single command installs K3s connected to MySQL, with flannel and its network policy controller disabled so Calico can take over:
curl -sfL https://get.k3s.io | K3S_TOKEN="ChangeThisClusterToken" \
K3S_KUBECONFIG_MODE="644" \
INSTALL_K3S_EXEC="--tls-san=k3s-endpoint \
--datastore-endpoint=mysql://k3s_user:StrongPasswordHere@tcp(mysql-endpoint:3306)/k3s_database \
--flannel-backend=none \
--disable-network-policy \
--cluster-cidr=10.42.0.0/16 \
--disable=traefik" sh -
Every flag matters here, so let’s go through them:
K3S_TOKENis the shared secret other nodes must present to join this cluster. Save it somewhere safe.--tls-san=k3s-endpointadds the stable name to the API server certificate, sokubectland joining nodes can use the name instead of an IP.--datastore-endpointtells K3s to store cluster state in MySQL through kine. The format ismysql://user:password@tcp(host:port)/database.--flannel-backend=noneand--disable-network-policyswitch off the built-in networking so Calico can replace it.--cluster-cidr=10.42.0.0/16is the pod CIDR you planned in Step 1.--disable=traefikskips the bundled ingress controller. This is optional, but most people prefer choosing their own ingress later.
Check that the service came up:
sudo systemctl status k3s
Now the important part: the node will report NotReady and pods like CoreDNS will sit in Pending or ContainerCreating. This is expected. There is no network plugin yet, so no pod can get an IP. Do not troubleshoot this, just continue to the next step.
Step 5: Install Calico
Calico installs in two parts: the Tigera operator, which manages Calico’s lifecycle, and an Installation resource that describes the network you want.
Install the operator:
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.30.0/manifests/tigera-operator.yaml
Download the default installation manifest:
wget https://raw.githubusercontent.com/projectcalico/calico/v3.30.0/manifests/custom-resources.yaml
Open it and set the pod CIDR to the exact value you gave --cluster-cidr:
nano custom-resources.yaml
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
ipPools:
- name: default-ipv4-ippool
blockSize: 26
cidr: 10.42.0.0/16
encapsulation: VXLANCrossSubnet
natOutgoing: Enabled
nodeSelector: all()
---
apiVersion: operator.tigera.io/v1
kind: APIServer
metadata:
name: default
spec: {}
The cidr field is the one to change. Note the comment in the original file: the ipPools section cannot be modified after installation, which is why getting the CIDR right now matters.
Apply it:
kubectl create -f custom-resources.yaml
Step 6: Verify the Cluster
Watch the pods come up. Calico needs a few minutes to pull images and start:
kubectl get pods --all-namespaces --watch
When everything settles, you should see output like this:
NAMESPACE NAME READY STATUS RESTARTS AGE
tigera-operator tigera-operator-6bbf97c9cf-w4k7b 1/1 Running 0 8m
calico-system calico-typha-5d8fbcc9d9-prrnm 1/1 Running 0 6m
calico-system calico-node-25scz 1/1 Running 0 6m
calico-system calico-kube-controllers-7bd8484dd4-zq7sm 1/1 Running 0 6m
calico-system csi-node-driver-2xffc 2/2 Running 0 6m
calico-apiserver calico-apiserver-79fd794858-7fj65 1/1 Running 0 4m
calico-apiserver calico-apiserver-79fd794858-j8p2x 1/1 Running 0 4m
kube-system coredns-77ccd57875-c2n2f 1/1 Running 0 10m
kube-system local-path-provisioner-957fdf8bc-55vxn 1/1 Running 0 10m
kube-system metrics-server-648b5df564-rsdts 1/1 Running 0 10m
The node should now be Ready:
kubectl get nodes -o wide
Finally, confirm that K3s is really using MySQL. Kine stores everything in a single table called kine:
sudo mysql -e "USE k3s_database; SHOW TABLES; SELECT COUNT(*) FROM kine;"
+------------------------+
| Tables_in_k3s_database |
+------------------------+
| kine |
+------------------------+
+----------+
| COUNT(*) |
+----------+
| 1524 |
+----------+
Every Kubernetes object in your cluster is now a row in that table. Back up this database and you have backed up your cluster state.
Scaling the Cluster Later
This is where the external datastore pays off. To add another server node (control plane), run the same install command from Step 4 on the new machine, with the same K3S_TOKEN and the same --datastore-endpoint. Because the state is in MySQL, the new server joins as an equal, there is no “first node” that everything depends on.
To add an agent node (worker), you only need the token and the server URL. I wrote a separate tutorial covering both cases in detail: How to Join a Node to an Existing K3s Cluster.
Common Mistakes and Troubleshooting
Pods stuck in Pending or ContainerCreating forever. Right after Step 4 this is normal, the CNI is missing. If it persists 10+ minutes after installing Calico, check the Calico pods: kubectl get pods -n calico-system and kubectl describe pod <name> -n calico-system. Slow image pulls are the usual cause on small connections.
Calico installed but pods still have no network, or nodes lose connectivity. Check for a CIDR mismatch: the cidr in custom-resources.yaml must equal --cluster-cidr from the K3s install, and neither may overlap your host LAN. Because the Calico IP pool cannot be edited after install, the cleanest fix is to uninstall (/usr/local/bin/k3s-uninstall.sh), drop and recreate the database, and reinstall with correct values. That sounds drastic, but on a fresh cluster it takes five minutes.
K3s fails to start with datastore connection errors. Run sudo journalctl -u k3s -f and look for MySQL errors. Verify the credentials manually: mysql -h mysql-endpoint -u k3s_user -p k3s_database. If that fails from a remote node, revisit the bind-address setting and your firewall rules for port 3306.
Special characters in the database password. The password is embedded in a URI, so characters like @, : or / break parsing. Either avoid them or percent-encode them in the --datastore-endpoint string.
kubectl permission denied for a normal user. We installed with K3S_KUBECONFIG_MODE="644" so any local user can read /etc/rancher/k3s/k3s.yaml. If you skipped that variable, either export KUBECONFIG after copying the file to your home directory or re-run the installer with the variable set.
Best Practices
- Do not expose MySQL to the world. Only the K3s server nodes need to reach port 3306. Restrict it with a firewall, see Setup Firewall Using UFW on Ubuntu.
- Back up the
kinedatabase regularly. A simplemysqldumpofk3s_databaseis a full cluster-state backup. Test restoring it once so you trust it. - Use strong, unique values for both the database password and the
K3S_TOKEN. Anyone with the token can join nodes to your cluster. - Keep the pod CIDR documented. Future-you, adding a node a year from now, needs to know it.
- Actually use Calico. You installed it for
NetworkPolicyenforcement, so start writing policies. My tutorial on Kubernetes Network Policy covers the patterns you need. - Pin versions deliberately. This tutorial uses Calico v3.30.0. When you upgrade later, read the Calico release notes and upgrade the operator first, not the other way around.
Conclusion
You now have a single-node K3s cluster that behaves like a much bigger one: Calico provides pod networking with real NetworkPolicy enforcement, and the entire cluster state sits in an external MySQL database through kine, ready to be backed up with tools you already know. Most importantly, the cluster can grow: additional server and agent nodes join against the same database and token without any redesign.
For next steps, add a worker with How to Join a Node to an Existing K3s Cluster, give your services a proper load balancer with Install MetalLB Load Balancer on Bare Metal Kubernetes, and start deploying applications the maintainable way with Getting Started with Helm Package Manager on Kubernetes.