{"id":164637,"date":"2026-03-26T03:00:10","date_gmt":"2026-03-26T00:00:10","guid":{"rendered":"https:\/\/computingforgeeks.com\/gitlab-cicd-pipeline-tutorial\/"},"modified":"2026-03-28T12:32:13","modified_gmt":"2026-03-28T09:32:13","slug":"gitlab-cicd-pipeline-tutorial","status":"publish","type":"post","link":"https:\/\/computingforgeeks.com\/gitlab-cicd-pipeline-tutorial\/","title":{"rendered":"GitLab CI\/CD Pipeline Tutorial: First Pipeline to Production"},"content":{"rendered":"\n<p>Most teams get GitLab installed and never touch the CI\/CD side because the docs dump too much theory before showing a working pipeline. This guide skips that. We install GitLab CE 18.10 on Ubuntu 24.04, register a shell runner, and build a real multi-stage pipeline that goes from code push to verified deployment. Every command was tested, every pipeline ran successfully, and every screenshot is from a live instance.<\/p>\n\n\n\n<p>The pipeline we build here uses a Flask application with three stages: build (virtual environment + dependencies), test (linting with flake8 and unit tests with pytest), and deploy (staging deployment with a health check). By the end, you will have a working GitLab CI\/CD setup that you can adapt for your own projects. If you need a standalone GitLab installation first, see our <a href=\"https:\/\/computingforgeeks.com\/install-gitlab-ce-ubuntu-debian\/\" target=\"_blank\" rel=\"noreferrer noopener\">guide on installing GitLab CE on Ubuntu 24.04 with SSL<\/a>.<\/p>\n\n\n\n<p><em>Tested <strong>March 2026<\/strong> on Ubuntu 24.04 LTS with GitLab CE 18.10.1, GitLab Runner 18.10.0, Python 3.12.3, Flask 3.1.1<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What You Need<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A server running Ubuntu 24.04 LTS with at least 8 GB RAM and 4 vCPUs. GitLab is resource-heavy: anything below 8 GB will result in slow reconfigures and 502 errors during startup.<\/li>\n\n<li>A domain or subdomain pointing to the server (we use <code>gitlab.computingforgeeks.com<\/code>). SSL certificates via Let&#8217;s Encrypt.<\/li>\n\n<li>Root or sudo access.<\/li>\n\n<li>Ports 80 (HTTP redirect), 443 (HTTPS), and 22 (SSH for git) open in the firewall.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Install GitLab CE 18.10 on Ubuntu 24.04<\/h2>\n\n\n\n<p>Install the prerequisite packages first:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt-get update\nsudo apt-get install -y curl openssh-server ca-certificates tzdata perl postfix<\/code><\/pre>\n\n\n\n<p>When the postfix configuration prompt appears, select <strong>Internet Site<\/strong> and use your server&#8217;s FQDN. Postfix handles notification emails from GitLab.<\/p>\n\n\n\n<p>Add the official GitLab CE repository:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -s https:\/\/packages.gitlab.com\/install\/repositories\/gitlab\/gitlab-ce\/script.deb.sh | sudo bash<\/code><\/pre>\n\n\n\n<p>Install GitLab CE with your external URL. Replace the domain with your own:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo EXTERNAL_URL=\"https:\/\/gitlab.yourdomain.com\" apt-get install -y gitlab-ce<\/code><\/pre>\n\n\n\n<p>The installation takes 3 to 5 minutes depending on your server specs. GitLab bundles its own Nginx, PostgreSQL, Redis, and Puma, so you do not need to install them separately. When it finishes, you will see:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Default admin account has been configured with following details:\nUsername: root\nPassword stored to \/etc\/gitlab\/initial_root_password. This file will be cleaned up in first reconfigure run after 24 hours.<\/code><\/pre>\n\n\n\n<p>Retrieve the initial root password before it gets auto-deleted:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo cat \/etc\/gitlab\/initial_root_password | grep Password:<\/code><\/pre>\n\n\n\n<p>Save this password somewhere safe. You will use it for the first login.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Configure SSL with Let&#8217;s Encrypt<\/h3>\n\n\n\n<p>If GitLab&#8217;s built-in Let&#8217;s Encrypt integration fails (common with fresh DNS records and DNSSEC propagation delays), you can use certbot directly. Stop GitLab&#8217;s Nginx temporarily and run the standalone challenge:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt-get install -y certbot\nsudo gitlab-ctl stop nginx\nsudo certbot certonly --standalone -d gitlab.yourdomain.com --non-interactive --agree-tos -m your@email.com<\/code><\/pre>\n\n\n\n<p>Point GitLab&#8217;s Nginx to the Let&#8217;s Encrypt certificates by adding these lines to <code>\/etc\/gitlab\/gitlab.rb<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo vi \/etc\/gitlab\/gitlab.rb<\/code><\/pre>\n\n\n\n<p>Add the following at the end of the file:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>letsencrypt['enable'] = false\nnginx['ssl_certificate'] = \"\/etc\/letsencrypt\/live\/gitlab.yourdomain.com\/fullchain.pem\"\nnginx['ssl_certificate_key'] = \"\/etc\/letsencrypt\/live\/gitlab.yourdomain.com\/privkey.pem\"<\/code><\/pre>\n\n\n\n<p>Apply the configuration:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo gitlab-ctl reconfigure<\/code><\/pre>\n\n\n\n<p>Verify all services are running:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo gitlab-ctl status<\/code><\/pre>\n\n\n\n<p>Every service should show <code>run<\/code> status. The key ones are puma (web), sidekiq (background jobs), gitaly (git operations), postgresql, and redis:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>run: gitaly: (pid 19606) 323s; run: log: (pid 18562) 507s\nrun: gitlab-workhorse: (pid 37089) 120s; run: log: (pid 19237) 386s\nrun: nginx: (pid 42280) 1s\nrun: postgresql: (pid 18620) 504s; run: log: (pid 18637) 501s\nrun: puma: (pid 38802) 23s; run: log: (pid 19140) 398s\nrun: redis: (pid 18473) 516s; run: log: (pid 18488) 515s\nrun: sidekiq: (pid 38690) 47s; run: log: (pid 19184) 391s<\/code><\/pre>\n\n\n\n<p>Open your browser and navigate to your GitLab URL. Log in with username <code>root<\/code> and the initial password you retrieved earlier.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/01-gitlab-login-page-2.png\" alt=\"GitLab CE 18.10 login page with HTTPS on Ubuntu 24.04\" class=\"wp-image-164631\" width=\"1024\" height=\"503\" title=\"\" srcset=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/01-gitlab-login-page-2.png 1920w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/01-gitlab-login-page-2-300x147.png 300w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/01-gitlab-login-page-2-1024x502.png 1024w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/01-gitlab-login-page-2-768x376.png 768w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/01-gitlab-login-page-2-1536x753.png 1536w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Install and Register GitLab Runner<\/h2>\n\n\n\n<p>The runner is what actually executes your CI\/CD jobs. GitLab itself just orchestrates. Without a registered runner, pipelines sit in &#8220;pending&#8221; forever.<\/p>\n\n\n\n<p>Add the GitLab Runner repository and install it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -s https:\/\/packages.gitlab.com\/install\/repositories\/runner\/gitlab-runner\/script.deb.sh | sudo bash\nsudo apt-get install -y gitlab-runner<\/code><\/pre>\n\n\n\n<p>Confirm the installation:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>gitlab-runner --version<\/code><\/pre>\n\n\n\n<p>You should see the version and build information:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Version:      18.10.0\nGit revision: ac71f4d8\nGit branch:   18-10-stable\nGO version:   go1.25.7\nBuilt:        2026-03-16T14:23:19Z\nOS\/Arch:      linux\/amd64<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Create a Runner Token<\/h3>\n\n\n\n<p>GitLab 18.x uses the new runner registration flow. Create a runner token via the API (you need a personal access token with <code>api<\/code> scope, which you can create in <strong>User Settings > Access Tokens<\/strong>):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -sk --header \"PRIVATE-TOKEN: YOUR_ACCESS_TOKEN\" \\\n  --request POST \"https:\/\/gitlab.yourdomain.com\/api\/v4\/user\/runners\" \\\n  --data \"runner_type=instance_type&description=shell-runner&tag_list=shell,ubuntu&run_untagged=true\"<\/code><\/pre>\n\n\n\n<p>The response includes a <code>token<\/code> field. Use it to register the runner with the shell executor:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo gitlab-runner register \\\n  --non-interactive \\\n  --url \"https:\/\/gitlab.yourdomain.com\/\" \\\n  --token \"glrt-YOUR_RUNNER_TOKEN\" \\\n  --executor \"shell\" \\\n  --description \"shell-runner\"<\/code><\/pre>\n\n\n\n<p>The output confirms successful registration:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Verifying runner... is valid                        runner=Vof6vwBgD\nRunner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!\nConfiguration (with the authentication token) was saved in \"\/etc\/gitlab-runner\/config.toml\"<\/code><\/pre>\n\n\n\n<p>Verify the runner is online:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo gitlab-runner status\nsudo gitlab-runner verify<\/code><\/pre>\n\n\n\n<p>The runner should show &#8220;Service is running&#8221; and &#8220;is valid&#8221; respectively.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/07-gitlab-runners.png\" alt=\"GitLab admin runners page showing registered shell runner online\" class=\"wp-image-164635\" width=\"1024\" height=\"503\" title=\"\" srcset=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/07-gitlab-runners.png 1920w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/07-gitlab-runners-300x147.png 300w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/07-gitlab-runners-1024x502.png 1024w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/07-gitlab-runners-768x376.png 768w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/07-gitlab-runners-1536x753.png 1536w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Create a Project with a Flask Application<\/h2>\n\n\n\n<p>We need a real codebase to build pipelines against. Create a new project in GitLab (either through the web UI or API), then clone it locally and add a simple Flask app with tests.<\/p>\n\n\n\n<p>Create <code>app.py<\/code> with two endpoints:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from flask import Flask, jsonify\n\napp = Flask(__name__)\n\n\n@app.route(\"\/\")\ndef home():\n    return jsonify({\"status\": \"running\", \"app\": \"flask-demo\", \"version\": \"1.0.0\"})\n\n\n@app.route(\"\/health\")\ndef health():\n    return jsonify({\"healthy\": True})\n\n\nif __name__ == \"__main__\":\n    app.run(host=\"0.0.0.0\", port=5000)<\/code><\/pre>\n\n\n\n<p>Create <code>requirements.txt<\/code> with pinned versions:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>flask==3.1.1\npytest==8.3.5<\/code><\/pre>\n\n\n\n<p>Create <code>test_app.py<\/code> with two unit tests:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import pytest\nfrom app import app\n\n\n@pytest.fixture\ndef client():\n    app.config[\"TESTING\"] = True\n    with app.test_client() as client:\n        yield client\n\n\ndef test_home(client):\n    response = client.get(\"\/\")\n    data = response.get_json()\n    assert response.status_code == 200\n    assert data[\"status\"] == \"running\"\n    assert data[\"version\"] == \"1.0.0\"\n\n\ndef test_health(client):\n    response = client.get(\"\/health\")\n    data = response.get_json()\n    assert response.status_code == 200\n    assert data[\"healthy\"] is True<\/code><\/pre>\n\n\n\n<p>The tests use Flask&#8217;s built-in test client. No external services, no database, no mocking. Both tests validate response codes and JSON payloads.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Write the CI\/CD Pipeline<\/h2>\n\n\n\n<p>GitLab pipelines are defined in a <code>.gitlab-ci.yml<\/code> file at the root of your repository. Every push to any branch triggers the pipeline automatically. Create the file:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>stages:\n  - build\n  - test\n  - deploy\n\nvariables:\n  PIP_CACHE_DIR: \"$CI_PROJECT_DIR\/.pip-cache\"\n\ncache:\n  paths:\n    - .pip-cache\/\n    - venv\/\n\nbuild:\n  stage: build\n  script:\n    - python3 -m venv venv\n    - source venv\/bin\/activate\n    - pip install -r requirements.txt\n    - pip list\n  artifacts:\n    paths:\n      - venv\/\n    expire_in: 1 hour\n\nlint:\n  stage: test\n  script:\n    - source venv\/bin\/activate\n    - pip install flake8\n    - flake8 app.py --max-line-length=120 --statistics\n  dependencies:\n    - build\n\ntest:\n  stage: test\n  script:\n    - source venv\/bin\/activate\n    - pytest test_app.py -v --tb=short --junitxml=report.xml\n  artifacts:\n    when: always\n    reports:\n      junit: report.xml\n    paths:\n      - report.xml\n    expire_in: 1 week\n  dependencies:\n    - build\n\ndeploy_staging:\n  stage: deploy\n  script:\n    - source venv\/bin\/activate\n    - echo \"Deploying flask-demo v1.0.0 to staging...\"\n    - nohup python3 app.py > \/tmp\/flask-staging.log 2>&1 &\n    - sleep 3\n    - curl -sf http:\/\/localhost:5000\/health | python3 -m json.tool\n    - echo \"Staging deployment verified successfully\"\n    - kill %1 2>\/dev\/null || true\n  environment:\n    name: staging\n    url: http:\/\/localhost:5000\n  dependencies:\n    - build\n  only:\n    - main<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">How This Pipeline Works<\/h3>\n\n\n\n<p>The pipeline has three stages that run sequentially. Jobs within the same stage run in parallel when multiple runners are available.<\/p>\n\n\n\n<p><strong>Build stage:<\/strong> Creates a Python virtual environment, installs dependencies from <code>requirements.txt<\/code>, and saves the <code>venv\/<\/code> directory as an artifact. Downstream jobs receive this artifact automatically without reinstalling packages. The <code>cache<\/code> block persists <code>.pip-cache\/<\/code> across pipeline runs, so pip downloads are cached between pushes.<\/p>\n\n\n\n<p><strong>Test stage:<\/strong> Two jobs run here. The <code>lint<\/code> job runs flake8 to catch style violations and syntax errors. The <code>test<\/code> job runs pytest with verbose output and generates a JUnit XML report. GitLab parses this report and displays test results directly in the merge request UI. Both jobs use <code>dependencies: [build]<\/code> to pull the venv artifact.<\/p>\n\n\n\n<p><strong>Deploy stage:<\/strong> Only triggers on the <code>main<\/code> branch. Starts the Flask app in the background, waits 3 seconds for it to initialize, then validates it with a health check via curl. The <code>environment<\/code> block tells GitLab to track this as a &#8220;staging&#8221; deployment, which shows up in the Environments page. In production you would replace this with an SSH deploy, <a href=\"https:\/\/computingforgeeks.com\/install-configure-ansible-linux\/\" target=\"_blank\" rel=\"noreferrer noopener\">Ansible playbook<\/a>, or container push to a <a href=\"https:\/\/computingforgeeks.com\/setup-private-docker-registry-on-ubuntu\/\" target=\"_blank\" rel=\"noreferrer noopener\">Docker registry<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Push and Watch the Pipeline Run<\/h2>\n\n\n\n<p>Commit all files and push to the main branch:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>git add -A\ngit commit -m \"Add Flask app with CI\/CD pipeline\"\ngit push origin main<\/code><\/pre>\n\n\n\n<p>Navigate to your project&#8217;s <strong>Build > Pipelines<\/strong> page. The pipeline starts immediately after the push:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/04-gitlab-pipeline-list.png\" alt=\"GitLab pipelines page showing a successful CI\/CD pipeline run with all stages passed\" class=\"wp-image-164632\" width=\"1024\" height=\"503\" title=\"\" srcset=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/04-gitlab-pipeline-list.png 1920w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/04-gitlab-pipeline-list-300x147.png 300w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/04-gitlab-pipeline-list-1024x502.png 1024w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/04-gitlab-pipeline-list-768x376.png 768w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/04-gitlab-pipeline-list-1536x753.png 1536w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p>Click on the pipeline to see the stage graph. Build runs first, then lint and test run in parallel, and finally deploy_staging runs:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/05-gitlab-pipeline-graph.png\" alt=\"GitLab pipeline graph showing build, test, and deploy stages with all jobs passed\" class=\"wp-image-164633\" width=\"1024\" height=\"503\" title=\"\" srcset=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/05-gitlab-pipeline-graph.png 1920w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/05-gitlab-pipeline-graph-300x147.png 300w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/05-gitlab-pipeline-graph-1024x502.png 1024w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/05-gitlab-pipeline-graph-768x376.png 768w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/05-gitlab-pipeline-graph-1536x753.png 1536w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Build Job Output<\/h3>\n\n\n\n<p>The build job creates the virtual environment and installs Flask 3.1.1 and pytest 8.3.5 with all their dependencies:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ python3 -m venv venv\n$ source venv\/bin\/activate\n$ pip install -r requirements.txt\nCollecting flask==3.1.1 (from -r requirements.txt (line 1))\n  Downloading flask-3.1.1-py3-none-any.whl.metadata (3.0 kB)\nCollecting pytest==8.3.5 (from -r requirements.txt (line 2))\n  Downloading pytest-8.3.5-py3-none-any.whl.metadata (7.6 kB)\nSuccessfully installed flask-3.1.1 pytest-8.3.5 ...<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Test Job Output<\/h3>\n\n\n\n<p>Both unit tests pass, confirming the Flask endpoints return the expected JSON responses:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ pytest test_app.py -v --tb=short --junitxml=report.xml\nplatform linux -- Python 3.12.3, pytest-8.3.5, pluggy-1.6.0\ntest_app.py::test_home PASSED                                            [ 50%]\ntest_app.py::test_health PASSED                                          [100%]\n============================== 2 passed in 0.12s ===============================<\/code><\/pre>\n\n\n\n<p>Click on any test job to see the full log with timing and artifact details:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/06-gitlab-test-job-log.png\" alt=\"GitLab job log showing pytest output with 2 tests passed\" class=\"wp-image-164634\" width=\"1024\" height=\"503\" title=\"\" srcset=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/06-gitlab-test-job-log.png 1920w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/06-gitlab-test-job-log-300x147.png 300w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/06-gitlab-test-job-log-1024x502.png 1024w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/06-gitlab-test-job-log-768x376.png 768w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/06-gitlab-test-job-log-1536x753.png 1536w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Deploy Job Output<\/h3>\n\n\n\n<p>The deploy stage starts the Flask app and validates it with a health check. The <code>curl -sf<\/code> flag makes curl fail silently on HTTP errors, so the job fails if the app does not start correctly:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ echo \"Deploying flask-demo v1.0.0 to staging...\"\nDeploying flask-demo v1.0.0 to staging...\n$ curl -sf http:\/\/localhost:5000\/health | python3 -m json.tool\n{\n    \"healthy\": true\n}\n$ echo \"Staging deployment verified successfully\"\nStaging deployment verified successfully<\/code><\/pre>\n\n\n\n<p>GitLab tracks the deployment in the <strong>Operate > Environments<\/strong> page, showing the deployment history and status:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/08-gitlab-environments.png\" alt=\"GitLab environments page showing staging deployment with last deployment timestamp\" class=\"wp-image-164636\" width=\"1024\" height=\"503\" title=\"\" srcset=\"https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/08-gitlab-environments.png 1920w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/08-gitlab-environments-300x147.png 300w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/08-gitlab-environments-1024x502.png 1024w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/08-gitlab-environments-768x376.png 768w, https:\/\/computingforgeeks.com\/wp-content\/uploads\/2026\/03\/08-gitlab-environments-1536x753.png 1536w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Key CI\/CD Concepts Explained<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Artifacts vs Cache<\/h3>\n\n\n\n<p>These two get confused constantly. <strong>Artifacts<\/strong> are files produced by a job that get passed to downstream jobs within the same pipeline. They are uploaded to GitLab and can be downloaded from the UI. <strong>Cache<\/strong> persists across pipelines. It is a best-effort optimization (might be cleared at any time) used for things like pip\/npm package caches. In our pipeline, the <code>venv\/<\/code> directory is an artifact (guaranteed to reach the test and deploy jobs), while <code>.pip-cache\/<\/code> is cache (speeds up pip downloads on subsequent runs).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">dependencies vs needs<\/h3>\n\n\n\n<p>The <code>dependencies<\/code> keyword controls which artifacts a job downloads. Without it, a job downloads artifacts from all previous jobs. The <code>needs<\/code> keyword (not used here) allows a job to start as soon as its listed dependencies finish, even if other jobs in the same stage are still running. Use <code>needs<\/code> when you have a complex pipeline graph and want to skip waiting for unrelated parallel jobs.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">JUnit Test Reports<\/h3>\n\n\n\n<p>The <code>--junitxml=report.xml<\/code> flag in pytest generates a JUnit XML file. When declared under <code>artifacts.reports.junit<\/code>, GitLab parses this file and shows test results inline in merge requests. Failed tests appear with their error messages directly in the diff view, so reviewers do not need to dig through job logs.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Shell Executor vs Docker Executor<\/h3>\n\n\n\n<p>We used the shell executor because it runs commands directly on the runner&#8217;s host, with zero container overhead. This is perfect for getting started and for servers where Docker is not installed. The trade-off is that jobs share the host filesystem and can interfere with each other. For production CI\/CD with multiple teams, the Docker executor provides isolation by running each job in a fresh container. Switch by changing <code>--executor \"docker\"<\/code> during registration and specifying a default image.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pipeline Configuration Reference<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Keyword<\/th><th>Purpose<\/th><th>Example<\/th><\/tr><\/thead><tbody><tr><td><code>stages<\/code><\/td><td>Define pipeline stage order<\/td><td><code>stages: [build, test, deploy]<\/code><\/td><\/tr><tr><td><code>variables<\/code><\/td><td>Set environment variables<\/td><td><code>PIP_CACHE_DIR: \".pip-cache\"<\/code><\/td><\/tr><tr><td><code>cache<\/code><\/td><td>Persist files across pipelines<\/td><td><code>paths: [.pip-cache\/]<\/code><\/td><\/tr><tr><td><code>artifacts<\/code><\/td><td>Pass files between jobs<\/td><td><code>paths: [venv\/]<\/code><\/td><\/tr><tr><td><code>dependencies<\/code><\/td><td>Control artifact downloads<\/td><td><code>dependencies: [build]<\/code><\/td><\/tr><tr><td><code>only\/except<\/code><\/td><td>Branch\/tag filtering<\/td><td><code>only: [main]<\/code><\/td><\/tr><tr><td><code>environment<\/code><\/td><td>Track deployments<\/td><td><code>name: staging<\/code><\/td><\/tr><tr><td><code>when<\/code><\/td><td>Control job execution<\/td><td><code>when: manual<\/code> or <code>when: always<\/code><\/td><\/tr><tr><td><code>needs<\/code><\/td><td>DAG ordering (skip stage wait)<\/td><td><code>needs: [build]<\/code><\/td><\/tr><tr><td><code>rules<\/code><\/td><td>Conditional job inclusion<\/td><td><code>rules: [{if: '$CI_COMMIT_BRANCH == \"main\"'}]<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Extending the Pipeline for Real Projects<\/h2>\n\n\n\n<p>The pipeline above is a working foundation. Here are practical patterns to add based on your project needs.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">SSH Deploy to a Remote Server<\/h3>\n\n\n\n<p>Replace the staging deploy script with an actual SSH deployment. Store the private key as a CI\/CD variable (<strong>Settings > CI\/CD > Variables<\/strong>, type &#8220;File&#8221;, key <code>SSH_PRIVATE_KEY<\/code>):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>deploy_production:\n  stage: deploy\n  script:\n    - chmod 600 \"$SSH_PRIVATE_KEY\"\n    - ssh -o StrictHostKeyChecking=no -i \"$SSH_PRIVATE_KEY\" deploy@10.0.1.50 \"\n        cd \/var\/www\/flask-demo &&\n        git pull origin main &&\n        source venv\/bin\/activate &&\n        pip install -r requirements.txt &&\n        sudo systemctl restart flask-demo\n      \"\n    - curl -sf https:\/\/app.yourdomain.com\/health\n  environment:\n    name: production\n    url: https:\/\/app.yourdomain.com\n  only:\n    - main\n  when: manual<\/code><\/pre>\n\n\n\n<p>The <code>when: manual<\/code> flag means this job appears as a play button in the pipeline. A team member must click it to deploy. This prevents accidental production deployments.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Branch-Specific Rules<\/h3>\n\n\n\n<p>The <code>rules<\/code> keyword (preferred over <code>only\/except<\/code> in newer GitLab versions) gives you fine-grained control over when jobs run:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>deploy_staging:\n  stage: deploy\n  rules:\n    - if: '$CI_COMMIT_BRANCH == \"develop\"'\n      when: always\n    - if: '$CI_COMMIT_BRANCH == \"main\"'\n      when: manual\n    - when: never\n  script:\n    - echo \"Deploying to staging...\"<\/code><\/pre>\n\n\n\n<p>This runs the deploy automatically on the <code>develop<\/code> branch, requires manual approval on <code>main<\/code>, and skips it on all other branches.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Security Scanning with SAST<\/h3>\n\n\n\n<p>GitLab includes built-in Static Application Security Testing. Add it with a single line using the <code>include<\/code> keyword:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>include:\n  - template: Security\/SAST.gitlab-ci.yml<\/code><\/pre>\n\n\n\n<p>This adds a SAST job that scans your code for common vulnerabilities. Results appear in the Security tab of merge requests. Note that some SAST analyzers require the Docker executor.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Troubleshooting<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Pipeline stuck on &#8220;pending&#8221;<\/h3>\n\n\n\n<p>This means no runner is available to pick up the job. Check that the runner is online with <code>sudo gitlab-runner verify<\/code>. If the job has tags (like <code>docker<\/code>), the runner must also have those tags. For untagged jobs, the runner must be configured with <code>run_untagged=true<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Error: &#8220;bash: python3: command not found&#8221;<\/h3>\n\n\n\n<p>The shell executor runs as the <code>gitlab-runner<\/code> user. Install Python system-wide with <code>sudo apt-get install -y python3 python3-venv python3-pip<\/code> so the runner user can access it. You can verify by running <code>sudo -u gitlab-runner python3 --version<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Error: &#8220;Permission denied&#8221; writing to project directory<\/h3>\n\n\n\n<p>The runner&#8217;s build directory lives under <code>\/home\/gitlab-runner\/builds\/<\/code>. If you see permission errors, check that the <code>gitlab-runner<\/code> user owns its home directory: <code>sudo chown -R gitlab-runner:gitlab-runner \/home\/gitlab-runner<\/code>.<\/p>\n\n\n\n<p>To go further, learn how to <a href=\"https:\/\/computingforgeeks.com\/gitlab-ci-docker-container-registry\/\" target=\"_blank\" rel=\"noreferrer noopener\">build and push Docker images with GitLab CI\/CD<\/a>, or dive into <a href=\"https:\/\/computingforgeeks.com\/gitlab-cicd-variables-inputs\/\" target=\"_blank\" rel=\"noreferrer noopener\">GitLab CI\/CD variables and inputs<\/a> for dynamic pipeline configuration.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What to Do Next<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Set up <a href=\"https:\/\/computingforgeeks.com\/install-jenkins-ubuntu-debian\/\" target=\"_blank\" rel=\"noreferrer noopener\">Jenkins as a secondary CI system<\/a> if you need multi-tool CI\/CD<\/li>\n\n<li>Add Docker image builds by switching to the Docker executor and using <code>docker build<\/code> in your pipeline<\/li>\n\n<li>Configure merge request pipelines that only run on MR branches using <code>rules: [{if: '$CI_PIPELINE_SOURCE == \"merge_request_event\"'}]<\/code><\/li>\n\n<li>Set up <a href=\"https:\/\/computingforgeeks.com\/install-kubernetes-cluster-ubuntu-jammy\/\" target=\"_blank\" rel=\"noreferrer noopener\">Kubernetes cluster<\/a> integration for container deployments<\/li>\n\n<li>Enable the GitLab Container Registry (built into GitLab CE) for storing Docker images alongside your code<\/li>\n<\/ul>\n\n\n","protected":false},"excerpt":{"rendered":"<p>Most teams get GitLab installed and never touch the CI\/CD side because the docs dump too much theory before showing a working pipeline. This guide skips that. We install GitLab CE 18.10 on Ubuntu 24.04, register a shell runner, and build a real multi-stage pipeline that goes from code push to verified deployment. Every command &#8230; <a title=\"GitLab CI\/CD Pipeline Tutorial: First Pipeline to Production\" class=\"read-more\" href=\"https:\/\/computingforgeeks.com\/gitlab-cicd-pipeline-tutorial\/\" aria-label=\"Read more about GitLab CI\/CD Pipeline Tutorial: First Pipeline to Production\">Read more<\/a><\/p>\n","protected":false},"author":3,"featured_media":164638,"comment_status":"open","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[329,690,35913,39796,299,50,81],"tags":[],"class_list":["post-164637","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-automation","category-dev","category-devops","category-gitlab","category-how-to","category-linux-tutorials","category-ubuntu"],"_links":{"self":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts\/164637","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/users\/3"}],"replies":[{"embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/comments?post=164637"}],"version-history":[{"count":1,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts\/164637\/revisions"}],"predecessor-version":[{"id":164954,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts\/164637\/revisions\/164954"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/media\/164638"}],"wp:attachment":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/media?parent=164637"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/categories?post=164637"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/tags?post=164637"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}