2026年7月15日
2026年7月15日
PythonのFlaskでWebアプリを作る方法|メモアプリで学ぶルーティング・テンプレート・SQLite・CSRF対策
Flask は、少ないコードで Web アプリを作れる Python のフレームワークです。この記事では、仮想環境の準備から始めて、メモを保存・一覧・削除できる小さなアプリを SQLite で作り、テンプレート、フォームの受け取り、CSRF 対策、SQL インジェクションを防ぐ書き方までを一通り説明します。最後に、作ったアプリを本番に出すときに開発用サーバーから何を切り替えるか(Gunicorn・Nginx・秘密鍵の扱い)も整理します。
Flask が向いている場面
Flask は、URL と処理を結びつける仕組み(ルーティング)、テンプレート、リクエストの扱いといった中心部分だけを持ち、ログインやフォーム検証、データベースの操作などは拡張ライブラリで足していく設計です。
- 向いている:社内向けの小さなツール、API サーバー、管理用の画面、Python のスクリプトに Web の画面を付けたいとき
- ほかも検討したい:ユーザー管理や管理画面まで最初から必要な大きめのサービス(Django のように機能がそろったフレームワークのほうが早いことがあります)
この記事のコードは Flask 3 系を前提にしています(2026年10月現在)。Flask が対応する Python のバージョンはリリースごとに見直されるため、新しく環境を作るときは Flask の公式ドキュメントの「Installation」で確認してください。
準備:仮想環境と Flask のインストール
プロジェクトごとに仮想環境を作り、その中に Flask と、CSRF 対策に使う Flask-WTF を入れます。
mkdir memo-app && cd memo-app
python3 -m venv .venv
source .venv/bin/activate # Windows の PowerShell では .venv\Scripts\Activate.ps1
pip install flask flask-wtf
flask --version
仮想環境を使うのは、プロジェクトごとにライブラリのバージョンを分けるためです。動作した組み合わせは pip freeze > requirements.txt で記録しておくと、別のサーバーでも同じ環境を再現できます。
最小のアプリを動かす
まずは1ページだけのアプリで、起動の流れを確認します。
# hello.py
from flask import Flask
app = Flask(__name__)
@app.get("/")
def index():
return "Hello, Flask!"
flask --app hello run --debug
ブラウザで http://127.0.0.1:5000/ を開くと文字が表示されます。@app.get("/") は「/ への GET リクエストでこの関数を呼ぶ」という指定です。--debug を付けると、コードを保存したときに自動で再起動し、エラー時にはブラウザに詳しい情報が出ます。
デバッグモードは開発中の手元だけで使ってください。 エラー画面から Python のコードを実行できるデバッガーが有効になるため、外から接続できる状態で動かすと、サーバーを乗っ取られる危険があります。
メモアプリを作る
ここからは、メモを保存・表示・削除できるアプリを作ります。ファイル構成は次のとおりです。
memo-app/
├── app.py
└── templates/
└── index.html
app.py
# app.py
import os
import sqlite3
from pathlib import Path
from flask import Flask, abort, flash, g, redirect, render_template, request, url_for
from flask_wtf.csrf import CSRFProtect
BASE_DIR = Path(__file__).resolve().parent
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"], # 未設定なら起動時にエラーにする
DATABASE=os.environ.get("MEMO_DB", str(BASE_DIR / "memo.sqlite3")),
MAX_CONTENT_LENGTH=1 * 1024 * 1024, # リクエストの大きさを 1MB までに制限
)
csrf = CSRFProtect(app)
def get_db() -> sqlite3.Connection:
if "db" not in g:
g.db = sqlite3.connect(app.config["DATABASE"])
g.db.row_factory = sqlite3.Row
return g.db
@app.teardown_appcontext
def close_db(exc):
db = g.pop("db", None)
if db is not None:
db.close()
@app.cli.command("init-db")
def init_db():
"""テーブルを作る(既にあれば何もしない)"""
db = get_db()
db.executescript(
"""
CREATE TABLE IF NOT EXISTS memos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""
)
db.commit()
print("データベースを初期化しました")
@app.get("/")
def index():
memos = get_db().execute(
"SELECT id, body, created_at FROM memos ORDER BY id DESC LIMIT 50"
).fetchall()
return render_template("index.html", memos=memos)
@app.post("/memos")
def create_memo():
body = request.form.get("body", "").strip()
if not body or len(body) > 500:
flash("メモは1〜500文字で入力してください")
return redirect(url_for("index"))
db = get_db()
db.execute("INSERT INTO memos (body) VALUES (?)", (body,))
db.commit()
flash("保存しました")
return redirect(url_for("index"))
@app.post("/memos/<int:memo_id>/delete")
def delete_memo(memo_id: int):
db = get_db()
cur = db.execute("DELETE FROM memos WHERE id = ?", (memo_id,))
db.commit()
if cur.rowcount == 0:
abort(404)
flash("削除しました")
return redirect(url_for("index"))
@app.get("/healthz")
def healthz():
return {"status": "ok"}
templates/index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>メモ帳</title>
</head>
<body>
<h1>メモ帳</h1>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul>{% for message in messages %}<li>{{ message }}</li>{% endfor %}</ul>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('create_memo') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea name="body" rows="3" maxlength="500" required></textarea>
<button type="submit">保存</button>
</form>
<ul>
{% for memo in memos %}
<li>
{{ memo["body"] }} <small>{{ memo["created_at"] }}</small>
<form method="post" action="{{ url_for('delete_memo', memo_id=memo['id']) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit">削除</button>
</form>
</li>
{% else %}
<li>メモはまだありません</li>
{% endfor %}
</ul>
</body>
</html>
起動する
秘密鍵(SECRET_KEY)はランダムな値を作って環境変数で渡します。コードやリポジトリには書きません。
export FLASK_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_hex(32))')"
flask --app app init-db
flask --app app run --debug
Windows の PowerShell では、$env:FLASK_SECRET_KEY = python -c "import secrets; print(secrets.token_hex(32))" のように設定します。
コードの要点:安全に作るための5つの決まりごと
小さなアプリでも、次の5点は最初から入れておくと後で困りません。
- SQL はプレースホルダーで値を渡す:
"... WHERE id = ?"と(memo_id,)のように、SQL 文と値を分けて渡します。f 文字列で値を SQL に埋め込むと、入力内容によって SQL が書き換えられる SQL インジェクションの原因になります。 - テンプレートの自動エスケープに任せる:Flask は
.htmlのテンプレートで{{ }}の中身を自動でエスケープするため、メモに<script>が書かれても文字として表示されます。利用者の入力に|safeを付けて、このエスケープを外さないでください。 - POST のフォームには CSRF トークンを入れる:
CSRFProtectを有効にすると、トークンの無い POST は拒否されます。ほかのサイトに仕込まれたフォームから、利用者になりすまして削除などを実行されるのを防ぐ仕組みです。 - POST の後はリダイレクトする:保存や削除の後に
redirect()で一覧へ戻すと、ブラウザの再読み込みで同じ投稿が二重に送られるのを防げます(PRG パターンと呼ばれます)。 - データを変える操作は POST にする:削除を GET のリンクにすると、リンクの先読みやクローラーのアクセスで消えてしまうことがあります。
flash() は次の画面に1回だけメッセージを渡す仕組みで、内部ではセッション(署名付きの Cookie)を使います。SECRET_KEY が無いと動かないのはこのためで、値が漏れると Cookie を偽造されるおそれがあります。
動作確認でつまずきやすい点
- **
KeyError: 'FLASK_SECRET_KEY'**:環境変数が未設定です。ターミナルを開き直すと消えるので、起動のたびに設定するか、開発時だけ.envファイル(リポジトリには入れない)から読み込む方法を使います。 - **
no such table: memos**:flask --app app init-dbを実行していないか、別の場所のデータベースファイルを見ています。MEMO_DBの値を確認してください。 - **
The CSRF token is missing.**:フォームにcsrf_tokenの hidden 項目が無いか、nameの綴りが違います。 - **
TemplateNotFound**:templatesフォルダがapp.pyと同じ階層に無いか、フォルダ名が違います。
本番に公開するときに切り替えること
flask run は開発用のサーバーで、本番での利用は想定されていません。公開するときは次の構成に切り替えます。
- WSGI サーバー:Gunicorn などで起動します(Gunicorn は Linux などの POSIX 環境向けで、Windows では動きません)。例:
gunicorn -w 2 -b 127.0.0.1:8000 app:app - リバースプロキシ:Nginx を前に置き、HTTPS の終端と静的ファイルの配信を任せます。Flask 側は
127.0.0.1だけで待ち受けます。 - プロキシの情報を正しく受け取る:Nginx の後ろでは、接続元 IP や HTTPS かどうかがそのままでは Flask に伝わりません。Werkzeug の
ProxyFixを、プロキシの段数に合わせて設定します。
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) # Nginx が1段の場合
app.config.update(SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Lax")
- 常駐と再起動:systemd のサービスにして、サーバーの再起動後も自動で立ち上がるようにします。秘密鍵などは
EnvironmentFileで、権限を絞ったファイルから渡します。 - 認証:この記事のアプリには利用者の区別が無く、URL を知っていれば誰でも削除できます。インターネットに公開するなら、Flask-Login などでログインを付けるか、Nginx の Basic 認証や IP 制限で利用者を絞ってください。
- バックアップ:SQLite のデータベースは1つのファイルなので、定期的に別の場所へコピーします。動作中にコピーする場合は、
sqlite3コマンドの.backupを使うと書き込み途中の状態を避けられます。
まとめ
- 仮想環境に Flask を入れ、
flask --app app run --debugで開発する。デバッグモードは手元だけで使う - ルートは
@app.get/@app.post、画面はrender_templateとテンプレートで作り、POST の後はリダイレクトする - SQL はプレースホルダー、出力はテンプレートの自動エスケープ、フォームは CSRF トークンで守る
SECRET_KEYはランダムな値を環境変数で渡し、コードやリポジトリに書かない- 本番は Gunicorn と Nginx に切り替え、
ProxyFix・Cookie の設定・認証・バックアップを整える