diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..97d23d4d613b86347c8abc54c8971718606a33af
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,66 @@
+image: containers.ethitter.com:443/docker/images/golang:latest
+
+variables:
+  REPO_NAME: git.ethitter.com/open-source/dyndnsd-client
+
+cache:
+  paths:
+    - /apt-cache
+    - $GOPATH/src/github.com
+    - $GOPATH/src/golang.org
+    - $GOPATH/src/google.golang.org
+    - $GOPATH/src/gopkg.in
+
+stages:
+  - test
+  - build
+
+before_script:
+  - mkdir -p $GOPATH/src/$(dirname $REPO_NAME)
+  - cp -R $CI_PROJECT_DIR $GOPATH/src/$REPO_NAME
+  - cd $GOPATH/src/$REPO_NAME
+  - cp config-sample.json config.json
+
+  - export CC=clang-5.0
+
+  - make dep
+
+unit_tests:
+  stage: test
+  script:
+    - make test
+
+race_detector:
+  stage: test
+  script:
+    - make race
+
+memory_sanitizer:
+  stage: test
+  script:
+    - make msan
+
+code_coverage:
+  stage: test
+  script:
+    - make coverage
+
+code_coverage_report:
+  stage: test
+  script:
+    - make coverhtml
+  only:
+  - master
+
+lint_code:
+  stage: test
+  script:
+    - make lint
+
+build:
+  stage: build
+  script:
+    - make
+  artifacts:
+    paths:
+      - dyndnsd-client/
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..bcb249972c4443bf82ac452084d359fed73bb146
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,39 @@
+PROJECT_NAME := "dyndnsd-client"
+PKG := "git.ethitter.com/debian/$(PROJECT_NAME)"
+PKG_LIST := $(shell go list ${PKG}/... | grep -v /vendor/)
+GO_FILES := $(shell find . -name '*.go' | grep -v /vendor/ | grep -v _test.go)
+
+.PHONY: all dep build clean test coverage coverhtml lint
+
+all: build
+
+lint:
+	@golint -set_exit_status ${PKG_LIST}
+
+test:
+	@go test -v ${PKG_LIST}
+
+race: dep
+	@go test -v -race ${PKG_LIST}
+
+msan: dep
+	@go test -v -msan ${PKG_LIST}
+
+coverage:
+	./tools/coverage.sh;
+
+coverhtml:
+	./tools/coverage.sh html;
+
+dep:
+	@go get -v -d ./...
+	@go get github.com/mitchellh/gox
+
+build: dep
+	@gox -output="${CI_PROJECT_DIR}/${PROJECT_NAME}/{{.Dir}}_{{.OS}}_{{.Arch}}" -parallel=6
+
+clean:
+	@rm -f $(PROJECT_NAME)
+
+help:
+	@grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
diff --git a/README.md b/README.md
index 9d0730094985161eb0e18e8a4b94bdfb1e5384ff..897ed93c529bb895bfe88758500b3e6e2d950f48 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-`dyndnsd-client`
+`dyndnsd-client` [![pipeline status](https://git.ethitter.com/open-source/dyndnsd-client/badges/master/pipeline.svg)](https://git.ethitter.com/open-source/dyndnsd-client/commits/master)
 ================
 
 Client for the [`dyndnsd`](https://github.com/cmur2/dyndnsd) daemon. Set up `dyndnsd` first, otherwise this is useless.
diff --git a/dyndnsd-client.go b/dyndnsd-client.go
index 1d7e40f8d928f0a585842817ee24d3b2cacabeba..7cd62a15f2d1f9da536a4d3ba62d2d2640987386 100644
--- a/dyndnsd-client.go
+++ b/dyndnsd-client.go
@@ -35,7 +35,7 @@ func init() {
 	flag.Parse()
 
 	if _, err := os.Stat(configPath); os.IsNotExist(err) {
-		fmt.Println("Config path does not exist. Aborting!\n")
+		fmt.Println("Config path does not exist. Aborting!")
 		flag.Usage()
 		os.Exit(3)
 	}
@@ -48,7 +48,7 @@ func init() {
 // Do the update!
 func main() {
 	// Base URL
-	endpoint, err := buildEndpointUrl()
+	endpoint, err := buildEndpointURL()
 	if err != nil {
 		logger.Println("Couldn't build endpoint URL")
 		logger.Printf("%s", err)
@@ -56,16 +56,17 @@ func main() {
 	}
 
 	// IPv4 is required
-	if ipv4, err := getUrl(ipv4Endpoint); err == nil {
-		if ipv4Valid := net.ParseIP(ipv4); ipv4Valid == nil {
+	if ipv4, err := getURL(ipv4Endpoint); err == nil {
+		ipv4Valid := net.ParseIP(ipv4)
+		if ipv4Valid == nil {
 			logger.Println("Invalid IPv4 address returned by endpoint")
 			logger.Printf("%s", err)
 			return
-		} else {
-			query := endpoint.Query()
-			query.Set("myip", ipv4Valid.String())
-			endpoint.RawQuery = query.Encode()
 		}
+
+		query := endpoint.Query()
+		query.Set("myip", ipv4Valid.String())
+		endpoint.RawQuery = query.Encode()
 	} else {
 		logger.Println("Couldn't retrieve IPv4 address")
 		logger.Printf("%s", err)
@@ -75,7 +76,7 @@ func main() {
 	// IPv6 is optional
 	// Leave empty to skip
 	if len(ipv6Endpoint) > 0 {
-		if ipv6, err := getUrl(ipv6Endpoint); err == nil {
+		if ipv6, err := getURL(ipv6Endpoint); err == nil {
 			if ipv6Valid := net.ParseIP(ipv6); ipv6Valid == nil {
 				logger.Println("Invalid IPv6 address returned by endpoint")
 				logger.Printf("%s", err)
@@ -103,7 +104,7 @@ func main() {
 	}
 
 	// Send the update
-	dyndns, err := getUrl(endpoint.String())
+	dyndns, err := getURL(endpoint.String())
 	if err != nil {
 		logger.Println("Couldn't update dyndnsd endpoint")
 		logger.Printf("%s", err)
@@ -115,7 +116,7 @@ func main() {
 }
 
 // Build endpoint URL from configuration
-func buildEndpointUrl() (*url.URL, error) {
+func buildEndpointURL() (*url.URL, error) {
 	var username string
 	var password string
 	var protocol string
@@ -132,27 +133,27 @@ func buildEndpointUrl() (*url.URL, error) {
 	cfg.Get("path", &path)
 	cfg.Get("dns_hostname", &hostname)
 
-	daemonUrl, err := url.Parse("")
+	daemonURL, err := url.Parse("")
 	if err != nil {
 		return nil, err
 	}
 
-	daemonUrl.Scheme = protocol
-	daemonUrl.Host = fmt.Sprintf("%s:%d", host, port)
-	daemonUrl.Path = path
+	daemonURL.Scheme = protocol
+	daemonURL.Host = fmt.Sprintf("%s:%d", host, port)
+	daemonURL.Path = path
 
 	userInfo := url.UserPassword(username, password)
-	daemonUrl.User = userInfo
+	daemonURL.User = userInfo
 
-	query := daemonUrl.Query()
+	query := daemonURL.Query()
 	query.Set("hostname", hostname)
-	daemonUrl.RawQuery = query.Encode()
+	daemonURL.RawQuery = query.Encode()
 
-	return daemonUrl, nil
+	return daemonURL, nil
 }
 
 // Retrieve given URL
-func getUrl(url string) (string, error) {
+func getURL(url string) (string, error) {
 	resp, err := http.Get(url)
 	if err != nil {
 		return "", err
diff --git a/tools/coverage.sh b/tools/coverage.sh
new file mode 100755
index 0000000000000000000000000000000000000000..0afd4f114465980f501559be19b79ac2ad39fe7f
--- /dev/null
+++ b/tools/coverage.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+#
+# Code coverage generation
+
+COVERAGE_DIR="${COVERAGE_DIR:-coverage}"
+PKG_LIST=$(go list ./... | grep -v /vendor/)
+
+# Create the coverage files directory
+mkdir -p "$COVERAGE_DIR";
+
+# Create a coverage file for each package
+for package in ${PKG_LIST}; do
+    go test -covermode=count -coverprofile "${COVERAGE_DIR}/${package##*/}.cov" "$package" ;
+done ;
+
+# Merge the coverage profile files
+echo 'mode: count' > "${COVERAGE_DIR}"/coverage.cov ;
+tail -q -n +2 "${COVERAGE_DIR}"/*.cov >> "${COVERAGE_DIR}"/coverage.cov ;
+
+# Display the global code coverage
+go tool cover -func="${COVERAGE_DIR}"/coverage.cov ;
+
+# If needed, generate HTML report
+if [ "$1" == "html" ]; then
+    go tool cover -html="${COVERAGE_DIR}"/coverage.cov -o coverage.html ;
+fi
+
+# Remove the coverage files directory
+rm -rf "$COVERAGE_DIR";