Installing the server
Qawk is one static binary and one PostgreSQL. The console is a page and a small Python proxy. There is no application server, no message broker and no cache to run.
What you are running
| Piece | Is | Needs |
|---|---|---|
| qawk | The server. Devices and the API talk to it. | PostgreSQL, a directory for artifacts, one port. |
| PostgreSQL | All the state. | 16 or later. A volume. |
| qawk-console | The web console and its proxy. | To reach the server. Nothing else. |
The server image also carries qawk-sim and qawk-load,
so you can test a real install with simulated devices without pulling anything
else.
The quickest route — Docker Hub, a build from source, compose, or no Docker at all — is in Get started. This page is the rest.
Configuration
Everything is an environment variable, read once at startup. Nothing is read from a file except the optional users list.
| Variable | Default | Meaning |
|---|---|---|
QAWK_DATABASE_URL | postgres://qawk:qawk@localhost:5432/qawk?sslmode=disable | PostgreSQL. |
QAWK_ADMIN_USERQAWK_ADMIN_PASSWORD | admin / admin | The administrator. Every permission, never stored, always works — which is how a new server is set up and how a lost password is fixed. Set the password. |
QAWK_USERS_FILE | none | A YAML file of users, applied at every start. See below. |
QAWK_LISTEN | :8080 | hawkBit's port, so devices need no change. |
QAWK_PUBLIC_URL | the request's address | Base of every link Qawk hands out. Set it when a proxy rewrites the address devices reach it at. |
QAWK_ARTIFACT_DIR | /var/lib/qawk/artifacts | Where artifact bytes live. |
QAWK_TENANT | DEFAULT | The tenant in the device URL. |
QAWK_POLLING_TIME | 00:05:00 | Device polling interval, until one is set through the API. |
QAWK_DB_MAX_CONNS | 20 | This instance's pool. Across all instances, keep under PostgreSQL's max_connections. |
QAWK_DB_WAIT | 5m | How long to wait for the database at startup. PostgreSQL's first start initialises its data directory before it listens — over a minute on a busy disk — so the default is generous. |
QAWK_AUDIT_DAYS | 180 | Audit retention. 0 keeps it for ever. |
QAWK_METRICS_TOKEN | none | Bearer token for /metrics. Empty leaves it open, for a scraper on an internal network. |
QAWK_SOURCE_URL | the Qawk repository | Where this build's source is. Answered by /qawk/v1/info and shown on the console's About page. If you have changed Qawk and other people use it over a network, point this at your own repository — see below. Blanking it stops the server. |
QAWK_LOG_LEVEL | info | debug logs every request. |
OTEL_* | none | The standard OpenTelemetry variables turn on OTLP export. |
The console has exactly one: HB_URL, the server it talks to.
Tenant settings — the gateway token, the polling interval, whether the
confirmation flow is on — are hawkBit's /rest/v1/system/configs, with
hawkBit's keys and defaults, stored in the database rather than in the
environment.
Users from a file
A new server has one user: the administrator. Others are created in the console,
through /qawk/v1/users, or listed in a file the server applies at every
start — which is how you keep users in configuration management:
users:
- username: release-manager-1
password: at-least-eight-characters
roles: [release-manager]
- username: operator-1
password: another-password
display_name: The night operator
roles: [operator]
docker run ... -v "$PWD/users.yaml:/etc/qawk/users.yaml:ro" \
-e QAWK_USERS_FILE=/etc/qawk/users.yaml padovanl/qawk:0.1.0
Missing users are created; existing ones are brought to the file's roles, display name, enabled flag and password. Users not in the file are left alone — the file adds and corrects, it does not delete. A file that breaks the rules stops the server, naming the user it could not take: a server that came up with half its users applied would be worse.
HTTPS
Devices send a bearer token on every poll. Over plain HTTP anyone on the path has your gateway token, and with it your whole fleet. So this is not a detail to leave for later — it is the difference between an update server and a way to install software on somebody's machines.
There are two ways to get it, and both are fine:
| Use when | |
|---|---|
| Qawk terminates TLS itself | A server in a centre, a single box, a lab — anywhere putting a proxy in front of one Go binary is a second thing to install, configure and patch for no gain. |
| A proxy terminates it | Something already owns your certificates, or you are on Kubernetes where it is the ingress's job. |
Qawk terminating TLS
Give it a certificate and a key. Both, or neither — half a pair is refused at startup, because a server that quietly fell back to plain HTTP because of a typo would be the worst possible answer to one.
docker run -d --name qawk --network qawk --restart unless-stopped -p 443:8443 \
-v qawk-artifacts:/var/lib/qawk \
-v /etc/qawk/tls:/tls:ro \
-e QAWK_LISTEN=':8443' \
-e QAWK_TLS_CERT=/tls/cert.pem \
-e QAWK_TLS_KEY=/tls/key.pem \
-e QAWK_DATABASE_URL='postgres://qawk:db-secret@qawk-db:5432/qawk?sslmode=disable' \
-e QAWK_ADMIN_PASSWORD='a-long-admin-password' \
qawk:local
| Variable | Does |
|---|---|
QAWK_TLS_CERTQAWK_TLS_KEY |
PEM certificate and private key. Both together turn HTTPS on. The certificate file should carry the full chain, leaf first. |
QAWK_TLS_CLIENT_CA |
A PEM bundle of authorities. Set it and a device must present its own certificate, verified before any handler runs — mutual TLS. |
QAWK_REDIRECT_HTTP |
An extra plain-HTTP address (:8080) that answers everything
with a 308 to the HTTPS one, so devices still pointed at the old URL are
told where to go. 308, not 301: it keeps the method and the body, so a device
POSTing its feedback does not have it turned into a GET and lost. |
TLS 1.2 is the floor — devices in the field outlive fashions in cryptography — and 1.3 is used whenever the client can. Check what you got:
openssl s_client -connect updates.example.com:443 </dev/null | grep -E 'Protocol|Cipher'
The container does not run as root, so it cannot bind port 443 inside
itself. Listen on a high port and let Docker map it —
-p 443:8443 with QAWK_LISTEN=':8443', as above. The
certificate files must be readable by uid 10001.
Making a certificate to test with
A self-signed certificate is enough to prove the setup works. It is not enough for production — nothing trusts it, so every client needs to be told to, and that is exactly the habit you do not want devices to have.
mkdir -p /etc/qawk/tls && cd /etc/qawk/tls
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-keyout key.pem -out cert.pem \
-subj "/CN=updates.example.com" \
-addext "subjectAltName=DNS:updates.example.com,DNS:localhost,IP:127.0.0.1"
chmod 644 cert.pem key.pem # the container runs as uid 10001
Put every name devices use in subjectAltName. Modern
clients ignore the common name entirely and check only the SAN list. A
certificate whose CN is right and whose SAN is missing is rejected by everything,
and the error rarely says so.
Then start the server as above and check it end to end:
# the certificate is its own authority, so hand it to curl
curl --cacert /etc/qawk/tls/cert.pem https://localhost/qawk/v1/info
# which protocol and cipher were negotiated
openssl s_client -connect localhost:443 -CAfile /etc/qawk/tls/cert.pem </dev/null \
2>/dev/null | grep -E 'Protocol|Cipher|Verify return'
# a client that does not know the certificate refuses -- as it should
curl https://localhost/qawk/v1/info # exit 60: certificate not trusted
# the plain-HTTP port redirects, keeping the method
curl -i -X POST http://localhost:8080/anything # 308, Location: https://...
For a device, point swupdate.cfg at https:// and give
it the certificate as a CA — Connecting devices has
the detail. With a real certificate from a public authority, or from your own
internal one, nothing has to be told anything.
Mutual TLS, if you want it
A gateway token is a fleet-wide secret: anything holding it can register a device. Client certificates raise that floor — a device must also hold a key signed by your authority, checked during the handshake, before any request exists.
# your device authority, once
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout ca-key.pem -out ca.pem -subj "/CN=Qawk device CA"
# a certificate per device
openssl req -newkey rsa:2048 -nodes -keyout dev-key.pem -out dev.csr \
-subj "/CN=device-0001"
openssl x509 -req -in dev.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out dev.pem -days 365
-e QAWK_TLS_CLIENT_CA=/tls/ca.pem
# with a certificate: served
curl --cacert cert.pem --cert dev.pem --key dev-key.pem https://localhost/qawk/v1/info
# without: refused during the handshake, before any handler runs
curl --cacert cert.pem https://localhost/qawk/v1/info
This applies to everything, not only devices — the console and your scripts need a client certificate too. And you now have certificates to issue, ship and renew per device. Worth it for a fleet you cannot physically trust; overkill for one on a private network.
A proxy terminating TLS instead
Leave QAWK_TLS_CERT unset and Qawk speaks plain HTTP. Three things
the proxy must get right:
- A generous body size and timeout. Artifacts are uploaded and downloaded
through it. nginx's default
client_max_body_sizeof 1 MB will reject every artifact you have. QAWK_PUBLIC_URL, if the proxy changes the address devices reach Qawk at — otherwise the download links Qawk hands out point somewhere the device cannot get to.- Range requests passed through, so a device on a bad line resumes a download instead of starting over.
server {
listen 443 ssl;
server_name updates.example.com;
client_max_body_size 0;
proxy_request_buffering off;
location / {
proxy_pass http://qawk:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
}
-e QAWK_PUBLIC_URL=https://updates.example.com
X-Forwarded-For matters beyond routing: it is the address the audit
log records for every change and every refused sign-in. Without it, every entry
says the proxy did it.
Letting a browser call the API
A browser refuses to hand a page an answer from another origin unless that server says it may. So a front end of your own — or the "send this request" panel in this documentation — needs the origin allowed:
-e QAWK_CORS_ORIGINS='https://tools.example.com,http://localhost:8099'
Off by default, and that is the right default: a server only devices and
scripts talk to gains nothing from it, and a browser refusing somebody else's
answer is a protection rather than an obstacle. Name the origins; *
is accepted but browsers refuse it for credentialed requests anyway. The
console does not need it — it is served through its own proxy, so the browser
sees one origin.
Kubernetes
server/deploy/kubernetes/
has a Deployment of three instances with probes, a Service, a PodDisruptionBudget,
an autoscaler, a shared artifact volume and — for a lab, not for production — a
PostgreSQL. The commands are at the top of qawk.yaml.
Two things to know before you run it:
- The artifact volume must be ReadWriteMany. Every instance serves downloads, so every instance needs the bytes. Artifacts are on a filesystem, not in object storage — this is a real limitation, and it is the thing to check first on a cluster whose storage class is RWO only.
- Only one instance runs the background jobs. They elect a leader through a PostgreSQL advisory lock; if it stops or loses its connection, another takes over within ten seconds. You do not need — and should not add — a singleton Deployment for the engine.
/live and /health are the probes. /live
says the process is up; /health says it can reach the database.
Backup and upgrade
Back up two things
- The database — every target, action, channel, release, user and audit entry.
- The artifact directory — the bytes devices download.
They must be consistent with each other: a database that references an artifact the filesystem does not have will hand devices a download that 404s. Snapshot both together, or dump the database after copying the artifacts — artifacts are only ever added, so a newer filesystem than database is harmless, while the reverse is not.
docker exec qawk-db pg_dump -U qawk -Fc qawk > qawk-$(date +%F).dump
docker run --rm -v qawk-artifacts:/src -v "$PWD":/out alpine \
tar czf /out/qawk-artifacts-$(date +%F).tgz -C /src .
Upgrading
Pull the new image and restart. Qawk applies its own schema migrations at startup, in order, each in a transaction, recording what it has applied.
docker pull padovanl/qawk:0.2.0
docker stop qawk && docker rm qawk
docker run -d --name qawk ... padovanl/qawk:0.2.0
Back up the database before an upgrade. Migrations go forward only — there is no down-migration, deliberately, because a down-migration that drops a column drops the data in it. If you need to go back, restore.
With several instances, take them through one at a time. Devices retry, so a poll that misses is a poll that happens thirty seconds later.
Starting over
Drop the database and the artifact volume. There is no other state — no temporary directory, no embedded store, nothing in the image:
docker rm -f qawk qawk-console qawk-db
docker volume rm qawk-db qawk-artifacts
If you have changed Qawk
Qawk is AGPL-3.0-or-later, and section 13 of that licence is the reason it is the AGPL rather than the GPL: if you modify Qawk and let other people use it over a network, those people are entitled to the source of your version.
Running it unchanged, or changing it and keeping it to yourself, carries no obligation at all — most deployments are one of those two and can stop reading here.
If you did change it, one variable is what makes the offer reach the people entitled to it:
-e QAWK_SOURCE_URL=https://git.example.com/acme/qawk
Qawk then reports it in two places, which between them are everyone who interacts with the server:
GET /qawk/v1/info, without credentials — an offer you have to sign in to read is not an offer;- the console's About → Licence and source, read from the server, so it names your build and not the project's.
Leaving the variable unset is fine — it falls back to the upstream repository, so you cannot blank the offer by forgetting. Setting it to whitespace stops the server at startup, because that would be hiding it on purpose.
What the licence does and does not ask of you, in full: Licence and credits.
Before you call it production
QAWK_ADMIN_PASSWORDis set, and is notadmin.- TLS terminates in front of it, and devices use
https://. QAWK_PUBLIC_URLmatches the address devices actually use.- The proxy's body size and read timeout are large enough for your artifacts.
- The database and the artifact directory are both backed up, and a restore has been tried at least once.
QAWK_METRICS_TOKENis set, or/metricsis not reachable from outside.- Real users exist with real roles — nobody is doing daily work as the administrator.
- The gateway token is not the one from the demo.
- If you have changed Qawk and others use it over a network,
QAWK_SOURCE_URLpoints at your repository.